feat: add collapsible resizable navigation
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -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"),
|
||||
]
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user