feat: add collapsible resizable navigation

This commit is contained in:
2026-07-31 10:25:47 +02:00
parent 2cbce03206
commit 6b36184f9d
14 changed files with 567 additions and 35 deletions
+16 -2
View File
@@ -32,7 +32,7 @@ Release-Tag oder ein bestimmter Commit verwendet werden:
```bash
/opt/netbox/venv/bin/pip install --upgrade --force-reinstall \
"git+https://git.mrblake.cc/MrBlake/Netbox-Utilities.git@v0.4.0"
"git+https://git.mrblake.cc/MrBlake/Netbox-Utilities.git@v0.5.0"
```
Alternativ kann hinter dem `@` die vollständige Commit-ID stehen.
@@ -198,7 +198,21 @@ NetBox-Objektberechtigungen des Benutzers eingeschränkt.
### Navigation personalisieren
Unter **Plugins > NetBox Utilities > Navigation personalisieren** sieht der Benutzer alle Menüs, für die er aktuell Berechtigungen besitzt. Die Pfeiltasten ändern die Reihenfolge; der Schalter blendet ein Menü aus. **Zurücksetzen** stellt die NetBox-Reihenfolge wieder her.
Unter **Plugins > NetBox Utilities > Navigation personalisieren** sieht der
Benutzer alle Menüs, für die er aktuell Berechtigungen besitzt. Die Pfeiltasten
ändern die Reihenfolge; der Schalter blendet ein Menü aus.
Zusätzlich kann jeder Benutzer die Desktop-Navigation auf einen reinen
**Icon-Modus** einklappen und die Breite der ausgeklappten Navigation zwischen
216 und 408 Pixeln einstellen. Am unteren Rand der Navigation stehen dafür
direkte Schaltflächen für **schmaler**, **einklappen/ausklappen** und **breiter**
zur Verfügung. Änderungen über diese Schaltflächen werden sofort im
Benutzerprofil gespeichert. Im Icon-Modus öffnen sich die Menüinhalte als
seitliches Flyout; auf kleinen beziehungsweise mobilen Ansichten bleibt das
normale NetBox-Menü erhalten.
**Zurücksetzen** stellt neben Reihenfolge und Sichtbarkeit auch die normale
ausgeklappte Breite von 288 Pixeln wieder her.
Die Funktion kann installationsweit über `navigation_customization_enabled = False` in `PLUGINS_CONFIG` abgeschaltet werden.
+1 -1
View File
@@ -5,7 +5,7 @@ class NetBoxUtilitiesConfig(PluginConfig):
name = "netbox_utilities"
verbose_name = "NetBox Utilities"
description = "Navigation, tenant filtering, bulk module installation, and atomic rack reordering"
version = "0.4.0"
version = "0.5.0"
author = "LKE"
base_url = "utilities"
min_version = "4.6.5"
@@ -0,0 +1,27 @@
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("netbox_utilities", "0002_utilitiessettings_tenant_required"),
]
operations = [
migrations.AddField(
model_name="navigationpreference",
name="sidebar_collapsed",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="navigationpreference",
name="sidebar_width",
field=models.PositiveSmallIntegerField(
default=288,
validators=[
django.core.validators.MinValueValidator(216),
django.core.validators.MaxValueValidator(408),
],
),
),
]
+6 -1
View File
@@ -1,5 +1,5 @@
from django.conf import settings
from django.core.validators import MinValueValidator
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
@@ -11,6 +11,11 @@ class NavigationPreference(models.Model):
)
menu_order = models.JSONField(default=list, blank=True)
hidden_menus = models.JSONField(default=list, blank=True)
sidebar_collapsed = models.BooleanField(default=False)
sidebar_width = models.PositiveSmallIntegerField(
default=288,
validators=[MinValueValidator(216), MaxValueValidator(408)],
)
updated = models.DateTimeField(auto_now=True)
class Meta:
+27
View File
@@ -1,5 +1,32 @@
from netbox.navigation.menu import get_menus
SIDEBAR_WIDTH_MIN = 216
SIDEBAR_WIDTH_MAX = 408
SIDEBAR_WIDTH_STEP = 24
SIDEBAR_WIDTH_DEFAULT = 288
def normalize_sidebar_width(value):
try:
width = int(value)
except (TypeError, ValueError):
return SIDEBAR_WIDTH_DEFAULT
width = min(SIDEBAR_WIDTH_MAX, max(SIDEBAR_WIDTH_MIN, width))
steps = (width - SIDEBAR_WIDTH_MIN + SIDEBAR_WIDTH_STEP // 2) // SIDEBAR_WIDTH_STEP
return SIDEBAR_WIDTH_MIN + steps * SIDEBAR_WIDTH_STEP
def update_sidebar_layout(collapsed, width, action):
width = normalize_sidebar_width(width)
if action == "toggle":
return not collapsed, width
if action == "smaller":
return collapsed, max(SIDEBAR_WIDTH_MIN, width - SIDEBAR_WIDTH_STEP)
if action == "larger":
return collapsed, min(SIDEBAR_WIDTH_MAX, width + SIDEBAR_WIDTH_STEP)
raise ValueError("Unbekannte Navigationsaktion.")
def _permitted_menu_items(menu, user):
for group in menu.groups:
@@ -1,18 +1,36 @@
(() => {
'use strict';
function applyNavigationPreferences() {
const dataElement = document.getElementById('netbox-utilities-navigation-data');
const sidebar = document.querySelector('#sidebar-menu > ul.navbar-nav');
if (!dataElement || !sidebar) return;
const dataElement = document.getElementById('netbox-utilities-navigation-data');
if (!dataElement) return;
let preferences;
try {
preferences = JSON.parse(dataElement.textContent);
} catch (_error) {
return;
}
let preferences;
try {
preferences = JSON.parse(dataElement.textContent);
} catch (_error) {
return;
}
const root = document.documentElement;
function normalizeWidth(value) {
const minimum = Number(preferences.width_min) || 216;
const maximum = Number(preferences.width_max) || 408;
const step = Number(preferences.width_step) || 24;
const requested = Number(value) || 288;
const snapped = minimum + Math.round((requested - minimum) / step) * step;
return Math.min(maximum, Math.max(minimum, snapped));
}
function applyRootLayout() {
preferences.width = normalizeWidth(preferences.width);
preferences.collapsed = Boolean(preferences.collapsed);
root.classList.add('netbox-utilities-navigation-layout');
root.classList.toggle('netbox-utilities-navigation-collapsed', preferences.collapsed);
root.style.setProperty('--netbox-utilities-sidebar-width', `${preferences.width}px`);
}
function applyMenuPreferences(sidebar) {
const descriptorByPath = new Map(
preferences.menus.map((menu) => [new URL(menu.first_url, document.baseURI).pathname, menu])
);
@@ -36,9 +54,157 @@
});
}
function updateMenuTitles(sidebar) {
sidebar.querySelectorAll(':scope > li.nav-item.dropdown > button.nav-link').forEach((button) => {
if (preferences.collapsed) {
button.dataset.netboxUtilitiesTitle = 'true';
button.title = button.getAttribute('aria-label') || '';
const menu = button.closest('.nav-item.dropdown')?.querySelector(':scope > .dropdown-menu.show');
if (menu) {
const top = Math.max(8, button.getBoundingClientRect().top);
menu.style.setProperty('--netbox-utilities-dropdown-top', `${top}px`);
}
} else if (button.dataset.netboxUtilitiesTitle) {
button.removeAttribute('title');
delete button.dataset.netboxUtilitiesTitle;
}
});
}
function configureCollapsedDropdowns(sidebar) {
sidebar.addEventListener('show.bs.dropdown', (event) => {
if (!preferences.collapsed) return;
const item = event.target.closest('.nav-item.dropdown');
if (!item) return;
sidebar.querySelectorAll(':scope > li.nav-item.dropdown').forEach((otherItem) => {
if (otherItem === item) return;
const otherButton = otherItem.querySelector(':scope > button.nav-link');
const otherMenu = otherItem.querySelector(':scope > .dropdown-menu');
otherButton?.classList.remove('show');
otherButton?.setAttribute('aria-expanded', 'false');
otherMenu?.classList.remove('show');
});
const button = item.querySelector(':scope > button.nav-link');
const menu = item.querySelector(':scope > .dropdown-menu');
if (!button || !menu) return;
const top = Math.max(8, button.getBoundingClientRect().top);
menu.style.setProperty('--netbox-utilities-dropdown-top', `${top}px`);
});
}
function createControl(action, icon, label) {
const button = document.createElement('button');
button.type = 'button';
button.className = 'btn btn-sm btn-ghost-secondary netbox-utilities-sidebar-control';
button.dataset.action = action;
button.title = label;
button.setAttribute('aria-label', label);
const iconElement = document.createElement('i');
iconElement.className = `mdi ${icon}`;
iconElement.setAttribute('aria-hidden', 'true');
button.appendChild(iconElement);
return button;
}
function updateControls(controls, sidebar, pending = false) {
const minimum = Number(preferences.width_min) || 216;
const maximum = Number(preferences.width_max) || 408;
const smaller = controls.querySelector('[data-action="smaller"]');
const larger = controls.querySelector('[data-action="larger"]');
const toggle = controls.querySelector('[data-action="toggle"]');
const toggleIcon = toggle.querySelector('i');
smaller.disabled = pending || preferences.collapsed || preferences.width <= minimum;
larger.disabled = pending || preferences.collapsed || preferences.width >= maximum;
toggle.disabled = pending;
toggleIcon.className = preferences.collapsed
? 'mdi mdi-arrow-expand-right'
: 'mdi mdi-arrow-collapse-left';
const toggleLabel = preferences.collapsed ? 'Navigation ausklappen' : 'Navigation auf Icons einklappen';
toggle.title = toggleLabel;
toggle.setAttribute('aria-label', toggleLabel);
updateMenuTitles(sidebar);
}
function applyAction(action) {
const step = Number(preferences.width_step) || 24;
if (action === 'toggle') preferences.collapsed = !preferences.collapsed;
if (action === 'smaller') preferences.width = normalizeWidth(preferences.width - step);
if (action === 'larger') preferences.width = normalizeWidth(preferences.width + step);
applyRootLayout();
}
function addLayoutControls(sidebarMenu, sidebar) {
if (!preferences.layout_update_url || !preferences.csrf_token) return;
const controls = document.createElement('div');
controls.className = 'netbox-utilities-sidebar-controls';
controls.setAttribute('role', 'group');
controls.setAttribute('aria-label', 'Navigationsgröße');
controls.append(
createControl('smaller', 'mdi-minus', 'Navigation schmaler'),
createControl('toggle', 'mdi-arrow-collapse-left', 'Navigation auf Icons einklappen'),
createControl('larger', 'mdi-plus', 'Navigation breiter')
);
const releaseInfo = sidebarMenu.querySelector(':scope > .text-muted');
sidebarMenu.insertBefore(controls, releaseInfo || null);
updateControls(controls, sidebar);
controls.addEventListener('click', async (event) => {
const button = event.target.closest('button[data-action]');
if (!button || button.disabled) return;
const previous = {collapsed: preferences.collapsed, width: preferences.width};
applyAction(button.dataset.action);
updateControls(controls, sidebar, true);
try {
const response = await fetch(preferences.layout_update_url, {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
'X-CSRFToken': preferences.csrf_token,
},
body: new URLSearchParams({action: button.dataset.action}),
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const saved = await response.json();
preferences.collapsed = Boolean(saved.collapsed);
preferences.width = normalizeWidth(saved.width);
controls.classList.remove('text-danger');
controls.removeAttribute('title');
} catch (error) {
preferences.collapsed = previous.collapsed;
preferences.width = previous.width;
controls.classList.add('text-danger');
controls.title = 'Die Navigationseinstellung konnte nicht gespeichert werden.';
console.error('Could not save NetBox navigation layout', error);
}
applyRootLayout();
updateControls(controls, sidebar);
});
}
function initializeNavigation() {
const sidebarMenu = document.getElementById('sidebar-menu');
const sidebar = document.querySelector('#sidebar-menu > ul.navbar-nav');
if (!sidebarMenu || !sidebar) return;
applyMenuPreferences(sidebar);
updateMenuTitles(sidebar);
configureCollapsedDropdowns(sidebar);
addLayoutControls(sidebarMenu, sidebar);
}
applyRootLayout();
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', applyNavigationPreferences, {once: true});
document.addEventListener('DOMContentLoaded', initializeNavigation, {once: true});
} else {
applyNavigationPreferences();
initializeNavigation();
}
})();
@@ -9,3 +9,115 @@
vertical-align: bottom;
white-space: nowrap;
}
.netbox-utilities-sidebar-controls {
display: none;
}
@media (min-width: 992px) {
html.netbox-utilities-navigation-layout {
--netbox-utilities-sidebar-effective-width: var(--netbox-utilities-sidebar-width, 18rem);
}
html.netbox-utilities-navigation-collapsed {
--netbox-utilities-sidebar-effective-width: 4.5rem;
}
html.netbox-utilities-navigation-layout .page > aside.navbar-vertical.navbar-expand-lg {
width: var(--netbox-utilities-sidebar-effective-width);
transition: width 160ms ease;
}
html.netbox-utilities-navigation-layout .page > aside.navbar-vertical.navbar-expand-lg ~ .navbar,
html.netbox-utilities-navigation-layout .page > aside.navbar-vertical.navbar-expand-lg ~ .page-wrapper {
margin-left: var(--netbox-utilities-sidebar-effective-width);
transition: margin-left 160ms ease;
}
html.netbox-utilities-navigation-layout .navbar-vertical.navbar-expand-lg img.motif {
width: var(--netbox-utilities-sidebar-effective-width);
}
.netbox-utilities-sidebar-controls {
position: sticky;
bottom: 0.5rem;
z-index: 2;
display: flex;
justify-content: center;
gap: 0.25rem;
margin: 0.75rem;
padding: 0.35rem;
border: 1px solid var(--tblr-border-color-translucent);
border-radius: var(--tblr-border-radius);
background: var(--tblr-bg-surface);
box-shadow: var(--tblr-box-shadow-sm);
}
.netbox-utilities-sidebar-control {
flex: 0 1 3rem;
padding-inline: 0.5rem;
}
html.netbox-utilities-navigation-collapsed .navbar-vertical.navbar-expand-lg > .container-fluid {
padding-inline: 0.35rem;
}
html.netbox-utilities-navigation-collapsed .navbar-vertical.navbar-expand-lg .navbar-brand,
html.netbox-utilities-navigation-collapsed .navbar-vertical.navbar-expand-lg img.motif,
html.netbox-utilities-navigation-collapsed #sidebar-menu > .text-muted,
html.netbox-utilities-navigation-collapsed #sidebar-menu .nav-link-title,
html.netbox-utilities-navigation-collapsed #sidebar-menu .dropdown-toggle::after {
display: none !important;
}
html.netbox-utilities-navigation-collapsed #sidebar-menu,
html.netbox-utilities-navigation-collapsed #sidebar-menu > .navbar-nav {
width: 100%;
}
html.netbox-utilities-navigation-collapsed #sidebar-menu .nav-item.dropdown > button.nav-link {
justify-content: center;
min-height: 2.75rem;
padding-inline: 0;
text-align: center;
}
html.netbox-utilities-navigation-collapsed #sidebar-menu .nav-link-icon {
width: auto;
margin: 0;
font-size: 1.5rem;
}
html.netbox-utilities-navigation-collapsed #sidebar-menu .nav-item.dropdown > .dropdown-menu {
position: fixed !important;
top: var(--netbox-utilities-dropdown-top, 0.5rem) !important;
right: auto !important;
bottom: auto !important;
left: var(--netbox-utilities-sidebar-effective-width) !important;
z-index: 1040;
width: min(22rem, calc(100vw - var(--netbox-utilities-sidebar-effective-width) - 1rem));
max-height: calc(100vh - var(--netbox-utilities-dropdown-top, 0.5rem) - 0.5rem);
overflow-y: auto;
transform: none !important;
border: 1px solid var(--tblr-border-color);
border-radius: var(--tblr-border-radius);
background: var(--tblr-bg-surface);
box-shadow: var(--tblr-box-shadow-lg);
}
html.netbox-utilities-navigation-collapsed .netbox-utilities-sidebar-controls {
margin-inline: 0;
}
html.netbox-utilities-navigation-collapsed .netbox-utilities-sidebar-control:not([data-action="toggle"]) {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
html.netbox-utilities-navigation-layout .page > aside.navbar-vertical.navbar-expand-lg,
html.netbox-utilities-navigation-layout .page > aside.navbar-vertical.navbar-expand-lg ~ .navbar,
html.netbox-utilities-navigation-layout .page > aside.navbar-vertical.navbar-expand-lg ~ .page-wrapper {
transition: none;
}
}
+25 -2
View File
@@ -1,16 +1,32 @@
from dcim.models import ModuleBay
from django.middleware.csrf import get_token
from django.urls import reverse
from netbox.plugins import PluginTemplateExtension
from tenancy.models import Tenant, TenantGroup
from .models import NavigationPreference
from .navigation_helpers import get_visible_menus, normalize_preferences
from .navigation_helpers import (
SIDEBAR_WIDTH_DEFAULT,
SIDEBAR_WIDTH_MAX,
SIDEBAR_WIDTH_MIN,
SIDEBAR_WIDTH_STEP,
get_visible_menus,
normalize_preferences,
normalize_sidebar_width,
)
from .runtime import navigation_customization_enabled, tenant_filter_enabled
class UtilitiesGlobalContent(PluginTemplateExtension):
def head(self):
request = self.context["request"]
preference_data = {"order": [], "hidden": [], "menus": []}
preference_data = {
"order": [],
"hidden": [],
"menus": [],
"collapsed": False,
"width": SIDEBAR_WIDTH_DEFAULT,
}
if request.user.is_authenticated and navigation_customization_enabled():
descriptors = get_visible_menus(request.user)
preference = NavigationPreference.objects.filter(user=request.user).first()
@@ -23,6 +39,13 @@ class UtilitiesGlobalContent(PluginTemplateExtension):
"order": order,
"hidden": hidden,
"menus": descriptors,
"collapsed": preference.sidebar_collapsed if preference else False,
"width": normalize_sidebar_width(preference.sidebar_width if preference else SIDEBAR_WIDTH_DEFAULT),
"width_min": SIDEBAR_WIDTH_MIN,
"width_max": SIDEBAR_WIDTH_MAX,
"width_step": SIDEBAR_WIDTH_STEP,
"layout_update_url": reverse("plugins:netbox_utilities:navigation_layout"),
"csrf_token": get_token(request),
}
return self.render(
"netbox_utilities/head.html",
@@ -22,11 +22,46 @@
</div>
{% else %}
<p class="text-secondary">
Verschiebe Menüs mit den Pfeilen und blende nicht benötigte Bereiche aus.
Passe Breite und Darstellung an, verschiebe Menüs mit den Pfeilen und blende nicht benötigte Bereiche aus.
Berechtigungen von NetBox werden dadurch nicht verändert.
</p>
<form method="post" id="navigation-preferences-form">
{% csrf_token %}
<fieldset class="border rounded p-3 mb-4">
<legend class="float-none w-auto px-2 fs-4 mb-2">Darstellung</legend>
<div class="form-check form-switch mb-3">
<input
class="form-check-input"
type="checkbox"
role="switch"
id="sidebar-collapsed"
name="sidebar_collapsed"
{% if sidebar_collapsed %}checked{% endif %}
>
<label class="form-check-label fw-medium" for="sidebar-collapsed">
Eingeklappt nur Hauptmenü-Icons anzeigen
</label>
</div>
<label class="form-label fw-medium" for="sidebar-width">
Breite im ausgeklappten Zustand:
<output id="sidebar-width-output" for="sidebar-width">{{ sidebar_width }} px</output>
</label>
<input
class="form-range"
type="range"
id="sidebar-width"
name="sidebar_width"
min="{{ sidebar_width_min }}"
max="{{ sidebar_width_max }}"
step="{{ sidebar_width_step }}"
value="{{ sidebar_width }}"
>
<div class="d-flex justify-content-between text-secondary small">
<span>Kleiner</span>
<span>Standard: 288 px</span>
<span>Größer</span>
</div>
</fieldset>
<div class="list-group mb-3" id="navigation-menu-editor">
{% for menu in menus %}
<div class="list-group-item d-flex align-items-center gap-3 navigation-menu-row">
@@ -71,18 +106,27 @@
<script>
(() => {
const editor = document.getElementById('navigation-menu-editor');
if (!editor) return;
editor.addEventListener('click', (event) => {
const button = event.target.closest('.move-up, .move-down');
if (!button) return;
const row = button.closest('.navigation-menu-row');
if (button.classList.contains('move-up') && row.previousElementSibling) {
editor.insertBefore(row, row.previousElementSibling);
}
if (button.classList.contains('move-down') && row.nextElementSibling) {
editor.insertBefore(row.nextElementSibling, row);
}
});
if (editor) {
editor.addEventListener('click', (event) => {
const button = event.target.closest('.move-up, .move-down');
if (!button) return;
const row = button.closest('.navigation-menu-row');
if (button.classList.contains('move-up') && row.previousElementSibling) {
editor.insertBefore(row, row.previousElementSibling);
}
if (button.classList.contains('move-down') && row.nextElementSibling) {
editor.insertBefore(row.nextElementSibling, row);
}
});
}
const width = document.getElementById('sidebar-width');
const output = document.getElementById('sidebar-width-output');
if (width && output) {
width.addEventListener('input', () => {
output.value = `${width.value} px`;
});
}
})();
</script>
{% endblock content %}
@@ -1,6 +1,13 @@
from django.test import SimpleTestCase
from netbox_utilities.navigation_helpers import normalize_preferences
from netbox_utilities.navigation_helpers import (
SIDEBAR_WIDTH_DEFAULT,
SIDEBAR_WIDTH_MAX,
SIDEBAR_WIDTH_MIN,
normalize_preferences,
normalize_sidebar_width,
update_sidebar_layout,
)
class NormalizePreferencesTest(SimpleTestCase):
@@ -19,3 +26,29 @@ class NormalizePreferencesTest(SimpleTestCase):
self.assertEqual(order, ["devices", "vpn"])
self.assertEqual(hidden, [])
class SidebarLayoutTest(SimpleTestCase):
def test_normalizes_width_to_supported_steps(self):
self.assertEqual(normalize_sidebar_width(None), SIDEBAR_WIDTH_DEFAULT)
self.assertEqual(normalize_sidebar_width(100), SIDEBAR_WIDTH_MIN)
self.assertEqual(normalize_sidebar_width(500), SIDEBAR_WIDTH_MAX)
self.assertEqual(normalize_sidebar_width(300), 312)
def test_toggles_icon_only_mode(self):
collapsed, width = update_sidebar_layout(False, SIDEBAR_WIDTH_DEFAULT, "toggle")
self.assertTrue(collapsed)
self.assertEqual(width, SIDEBAR_WIDTH_DEFAULT)
def test_resizes_within_limits(self):
self.assertEqual(update_sidebar_layout(False, SIDEBAR_WIDTH_MIN, "smaller")[1], SIDEBAR_WIDTH_MIN)
self.assertEqual(update_sidebar_layout(False, SIDEBAR_WIDTH_MAX, "larger")[1], SIDEBAR_WIDTH_MAX)
self.assertLess(
update_sidebar_layout(False, SIDEBAR_WIDTH_DEFAULT, "smaller")[1],
SIDEBAR_WIDTH_DEFAULT,
)
def test_rejects_unknown_action(self):
with self.assertRaisesMessage(ValueError, "Unbekannte Navigationsaktion"):
update_sidebar_layout(False, SIDEBAR_WIDTH_DEFAULT, "invalid")
@@ -0,0 +1,37 @@
import json
from unittest.mock import MagicMock, patch
from django.test import RequestFactory, SimpleTestCase
from netbox_utilities.views import NavigationLayoutView
class NavigationLayoutViewTest(SimpleTestCase):
def setUp(self):
self.factory = RequestFactory()
@patch("netbox_utilities.views.navigation_customization_enabled", return_value=True)
@patch("netbox_utilities.views.NavigationPreference.objects.get_or_create")
def test_toggles_and_persists_icon_mode(self, get_or_create, _feature_enabled):
preference = MagicMock(sidebar_collapsed=False, sidebar_width=288)
get_or_create.return_value = (preference, False)
request = self.factory.post("/plugins/utilities/navigation/layout/", {"action": "toggle"})
request.user = MagicMock()
response = NavigationLayoutView().post(request)
self.assertEqual(response.status_code, 200)
self.assertEqual(json.loads(response.content), {"collapsed": True, "width": 288})
self.assertTrue(preference.sidebar_collapsed)
preference.save.assert_called_once_with(update_fields=("sidebar_collapsed", "sidebar_width", "updated"))
@patch("netbox_utilities.views.navigation_customization_enabled", return_value=True)
@patch("netbox_utilities.views.NavigationPreference.objects.get_or_create")
def test_rejects_unknown_layout_action(self, get_or_create, _feature_enabled):
get_or_create.return_value = (MagicMock(sidebar_collapsed=False, sidebar_width=288), False)
request = self.factory.post("/plugins/utilities/navigation/layout/", {"action": "unknown"})
request.user = MagicMock()
response = NavigationLayoutView().post(request)
self.assertEqual(response.status_code, 400)
+1
View File
@@ -7,6 +7,7 @@ app_name = "netbox_utilities"
urlpatterns = [
path("modules/bulk-install/", views.BulkModuleInstallView.as_view(), name="bulk_module_install"),
path("navigation/", views.NavigationPreferencesView.as_view(), name="navigation_preferences"),
path("navigation/layout/", views.NavigationLayoutView.as_view(), name="navigation_layout"),
path("settings/", views.UtilitiesSettingsView.as_view(), name="settings"),
path("tenant/select/", views.SelectTenantView.as_view(), name="select_tenant"),
]
+45 -2
View File
@@ -1,7 +1,7 @@
from dcim.models import Device, Module
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
from django.http import HttpResponseBadRequest
from django.http import HttpResponseBadRequest, JsonResponse
from django.shortcuts import redirect, render
from django.urls import reverse
from django.utils.http import url_has_allowed_host_and_scheme
@@ -18,7 +18,16 @@ from .middleware import (
)
from .models import NavigationPreference, UtilitiesSettings
from .module_installation import BulkModuleInstallError, install_modules
from .navigation_helpers import get_visible_menus, normalize_preferences
from .navigation_helpers import (
SIDEBAR_WIDTH_DEFAULT,
SIDEBAR_WIDTH_MAX,
SIDEBAR_WIDTH_MIN,
SIDEBAR_WIDTH_STEP,
get_visible_menus,
normalize_preferences,
normalize_sidebar_width,
update_sidebar_layout,
)
from .runtime import (
clear_runtime_settings_cache,
navigation_customization_enabled,
@@ -116,6 +125,8 @@ class NavigationPreferencesView(LoginRequiredMixin, View):
if "reset" in request.POST:
preference.menu_order = []
preference.hidden_menus = []
preference.sidebar_collapsed = False
preference.sidebar_width = SIDEBAR_WIDTH_DEFAULT
else:
descriptors = get_visible_menus(request.user)
available_keys = [item["key"] for item in descriptors]
@@ -126,6 +137,8 @@ class NavigationPreferencesView(LoginRequiredMixin, View):
)
preference.menu_order = order
preference.hidden_menus = hidden
preference.sidebar_collapsed = request.POST.get("sidebar_collapsed") == "on"
preference.sidebar_width = normalize_sidebar_width(request.POST.get("sidebar_width"))
preference.save()
messages.success(request, "Die persönliche Navigation wurde gespeichert.")
return redirect("plugins:netbox_utilities:navigation_preferences")
@@ -146,10 +159,40 @@ class NavigationPreferencesView(LoginRequiredMixin, View):
{
"menus": menus,
"feature_enabled": navigation_customization_enabled(),
"sidebar_collapsed": preference.sidebar_collapsed if preference else False,
"sidebar_width": normalize_sidebar_width(
preference.sidebar_width if preference else SIDEBAR_WIDTH_DEFAULT
),
"sidebar_width_min": SIDEBAR_WIDTH_MIN,
"sidebar_width_max": SIDEBAR_WIDTH_MAX,
"sidebar_width_step": SIDEBAR_WIDTH_STEP,
},
)
class NavigationLayoutView(LoginRequiredMixin, View):
http_method_names = ["post"]
def post(self, request):
if not navigation_customization_enabled():
return HttpResponseBadRequest("Die Navigationspersonalisierung ist deaktiviert.")
preference, _ = NavigationPreference.objects.get_or_create(user=request.user)
try:
collapsed, width = update_sidebar_layout(
preference.sidebar_collapsed,
preference.sidebar_width,
request.POST.get("action"),
)
except ValueError as error:
return HttpResponseBadRequest(str(error))
preference.sidebar_collapsed = collapsed
preference.sidebar_width = width
preference.save(update_fields=("sidebar_collapsed", "sidebar_width", "updated"))
return JsonResponse({"collapsed": collapsed, "width": width})
class UtilitiesSettingsView(LoginRequiredMixin, UserPassesTestMixin, View):
template_name = "netbox_utilities/settings.html"
raise_exception = True
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "netbox-utilities"
version = "0.4.0"
version = "0.5.0"
description = "Navigation, tenant filtering, bulk module installation, and atomic rack reordering for NetBox 4.6"
readme = "README.md"
requires-python = ">=3.12"