feat: add personalized navigation and tenant filtering

This commit is contained in:
2026-07-28 16:40:50 +02:00
commit 54f6a3f6c9
27 changed files with 1142 additions and 0 deletions
+28
View File
@@ -0,0 +1,28 @@
from netbox.plugins import PluginConfig
class NetBoxUtilitiesConfig(PluginConfig):
name = "netbox_utilities"
verbose_name = "NetBox Utilities"
description = "Personal navigation and a global tenant filter"
version = "0.1.0"
author = "LKE"
base_url = "utilities"
min_version = "4.6.5"
max_version = "4.6.99"
default_settings = {
"navigation_customization_enabled": True,
"tenant_filter_enabled": True,
}
middleware = [
"netbox_utilities.middleware.GlobalTenantFilterMiddleware",
]
def ready(self):
super().ready()
from .tenant_scope import install_search_filter
install_search_filter()
config = NetBoxUtilitiesConfig
+21
View File
@@ -0,0 +1,21 @@
from django import forms
from .models import UtilitiesSettings
class UtilitiesSettingsForm(forms.ModelForm):
class Meta:
model = UtilitiesSettings
fields = ("tenant_filter_enabled",)
labels = {
"tenant_filter_enabled": "Globalen Mandantenfilter aktivieren",
}
help_texts = {
"tenant_filter_enabled": (
"Blendet das Mandanten-Dropdown ein und ergänzt unterstützte NetBox-Listen "
"automatisch um den gewählten Mandantenfilter."
),
}
widgets = {
"tenant_filter_enabled": forms.CheckboxInput(attrs={"class": "form-check-input"}),
}
+68
View File
@@ -0,0 +1,68 @@
from django.urls import Resolver404, resolve
from tenancy.models import Tenant
from .runtime import tenant_filter_enabled
from .tenant_scope import active_tenant_id
SESSION_TENANT_KEY = "netbox_utilities.selected_tenant_id"
class GlobalTenantFilterMiddleware:
"""Apply the selected tenant to tenant-aware NetBox list filtersets."""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
request.netbox_utilities_tenant = None
tenant_id = self._get_selected_tenant_id(request)
token = active_tenant_id.set(tenant_id)
try:
if tenant_id is not None and request.method in {"GET", "HEAD"}:
self._inject_filter_parameter(request, tenant_id)
return self.get_response(request)
finally:
active_tenant_id.reset(token)
@staticmethod
def _get_selected_tenant_id(request):
if not tenant_filter_enabled() or not request.user.is_authenticated:
request.session.pop(SESSION_TENANT_KEY, None)
return None
tenant_id = request.session.get(SESSION_TENANT_KEY)
if tenant_id is None:
return None
tenant = Tenant.objects.restrict(request.user, "view").filter(pk=tenant_id).first()
if tenant is None:
request.session.pop(SESSION_TENANT_KEY, None)
return None
request.netbox_utilities_tenant = tenant
return tenant.pk
@staticmethod
def _inject_filter_parameter(request, tenant_id):
try:
match = resolve(request.path_info)
except Resolver404:
return
# Plugin, API, login and admin endpoints must never be changed implicitly.
if "netbox_utilities" in match.namespaces or "api" in match.namespaces:
return
parameter = None
if match.view_name == "tenancy:tenant_list":
parameter = "id"
else:
view_class = getattr(match.func, "view_class", None)
filterset = getattr(view_class, "filterset", None)
if filterset and "tenant_id" in getattr(filterset, "base_filters", {}):
parameter = "tenant_id"
if parameter:
query = request.GET.copy()
query.setlist(parameter, [str(tenant_id)])
request.GET = query
@@ -0,0 +1,54 @@
import django.core.validators
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="UtilitiesSettings",
fields=[
(
"singleton_id",
models.PositiveSmallIntegerField(
default=1,
editable=False,
primary_key=True,
serialize=False,
validators=[django.core.validators.MinValueValidator(1)],
),
),
("tenant_filter_enabled", models.BooleanField(default=True)),
("updated", models.DateTimeField(auto_now=True)),
],
options={
"verbose_name": "NetBox Utilities setting",
"verbose_name_plural": "NetBox Utilities settings",
},
),
migrations.CreateModel(
name="NavigationPreference",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("menu_order", models.JSONField(blank=True, default=list)),
("hidden_menus", models.JSONField(blank=True, default=list)),
("updated", models.DateTimeField(auto_now=True)),
(
"user",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="netbox_utilities_navigation",
to=settings.AUTH_USER_MODEL,
),
),
],
options={"ordering": ("user",)},
),
]
+1
View File
@@ -0,0 +1 @@
+42
View File
@@ -0,0 +1,42 @@
from django.conf import settings
from django.core.validators import MinValueValidator
from django.db import models
class NavigationPreference(models.Model):
user = models.OneToOneField(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="netbox_utilities_navigation",
)
menu_order = models.JSONField(default=list, blank=True)
hidden_menus = models.JSONField(default=list, blank=True)
updated = models.DateTimeField(auto_now=True)
class Meta:
ordering = ("user",)
def __str__(self):
return f"Navigation for {self.user}"
class UtilitiesSettings(models.Model):
singleton_id = models.PositiveSmallIntegerField(
primary_key=True,
default=1,
editable=False,
validators=[MinValueValidator(1)],
)
tenant_filter_enabled = models.BooleanField(default=True)
updated = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = "NetBox Utilities setting"
verbose_name_plural = "NetBox Utilities settings"
def save(self, *args, **kwargs):
self.singleton_id = 1
return super().save(*args, **kwargs)
def __str__(self):
return "NetBox Utilities settings"
+14
View File
@@ -0,0 +1,14 @@
from netbox.plugins import PluginMenuItem
menu_items = (
PluginMenuItem(
link="plugins:netbox_utilities:navigation_preferences",
link_text="Navigation personalisieren",
auth_required=True,
),
PluginMenuItem(
link="plugins:netbox_utilities:settings",
link_text="Einstellungen",
staff_only=True,
),
)
+50
View File
@@ -0,0 +1,50 @@
from netbox.navigation.menu import get_menus
def _permitted_menu_items(menu, user):
for group in menu.groups:
for item in group.items:
if getattr(item, "auth_required", False) and not user.is_authenticated:
continue
if item.staff_only and not user.is_superuser:
continue
if not user.has_perms(item.permissions):
continue
yield item
def get_visible_menus(user):
"""Return stable descriptors for every sidebar menu visible to the user."""
descriptors = []
used_keys = set()
for index, menu in enumerate(get_menus()):
items = list(_permitted_menu_items(menu, user))
if not items:
continue
first_url = str(items[0].url)
key = first_url or menu.icon_class or f"menu-{index}"
if key in used_keys:
key = f"{key}#{index}"
used_keys.add(key)
descriptors.append(
{
"key": key,
"label": str(menu.label),
"icon_class": menu.icon_class,
"first_url": first_url,
}
)
return descriptors
def normalize_preferences(menu_order, hidden_menus, available_keys):
"""Discard forged/stale keys and append newly introduced menus."""
allowed = list(dict.fromkeys(available_keys))
allowed_set = set(allowed)
order = [key for key in dict.fromkeys(menu_order) if key in allowed_set]
order.extend(key for key in allowed if key not in order)
hidden = [key for key in dict.fromkeys(hidden_menus) if key in allowed_set]
return order, hidden
+33
View File
@@ -0,0 +1,33 @@
from django.core.cache import cache
from django.db import OperationalError, ProgrammingError
from netbox.plugins import get_plugin_config
from .models import UtilitiesSettings
SETTINGS_CACHE_KEY = "netbox_utilities.runtime_settings.v1"
def navigation_customization_enabled():
return bool(get_plugin_config("netbox_utilities", "navigation_customization_enabled"))
def tenant_filter_enabled():
if not get_plugin_config("netbox_utilities", "tenant_filter_enabled"):
return False
enabled = cache.get(SETTINGS_CACHE_KEY)
if enabled is None:
try:
enabled = (
UtilitiesSettings.objects.filter(singleton_id=1).values_list("tenant_filter_enabled", flat=True).first()
)
except (OperationalError, ProgrammingError):
# The middleware can be imported before the first plugin migration.
enabled = True
enabled = True if enabled is None else enabled
cache.set(SETTINGS_CACHE_KEY, enabled, timeout=30)
return bool(enabled)
def clear_runtime_settings_cache():
cache.delete(SETTINGS_CACHE_KEY)
@@ -0,0 +1,44 @@
(() => {
'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;
let preferences;
try {
preferences = JSON.parse(dataElement.textContent);
} catch (_error) {
return;
}
const descriptorByPath = new Map(
preferences.menus.map((menu) => [new URL(menu.first_url, document.baseURI).pathname, menu])
);
const nodeByKey = new Map();
sidebar.querySelectorAll(':scope > li.nav-item.dropdown').forEach((node) => {
const firstLink = node.querySelector('.dropdown-menu a[href]');
if (!firstLink) return;
const descriptor = descriptorByPath.get(new URL(firstLink.href, document.baseURI).pathname);
if (descriptor) nodeByKey.set(descriptor.key, node);
});
preferences.order.forEach((key) => {
const node = nodeByKey.get(key);
if (node) sidebar.appendChild(node);
});
const hidden = new Set(preferences.hidden);
nodeByKey.forEach((node, key) => {
node.classList.toggle('d-none', hidden.has(key));
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', applyNavigationPreferences, {once: true});
} else {
applyNavigationPreferences();
}
})();
@@ -0,0 +1,11 @@
.netbox-utilities-tenant .tenant-filter-menu {
min-width: 20rem;
}
.netbox-utilities-tenant .tenant-filter-label {
max-width: 14rem;
overflow: hidden;
text-overflow: ellipsis;
vertical-align: bottom;
white-space: nowrap;
}
+50
View File
@@ -0,0 +1,50 @@
from netbox.plugins import PluginTemplateExtension
from tenancy.models import Tenant
from .models import NavigationPreference
from .navigation_helpers import get_visible_menus, normalize_preferences
from .runtime import navigation_customization_enabled, tenant_filter_enabled
class UtilitiesGlobalContent(PluginTemplateExtension):
def head(self):
request = self.context["request"]
preference_data = {"order": [], "hidden": [], "menus": []}
if request.user.is_authenticated and navigation_customization_enabled():
descriptors = get_visible_menus(request.user)
preference = NavigationPreference.objects.filter(user=request.user).first()
order, hidden = normalize_preferences(
preference.menu_order if preference else [],
preference.hidden_menus if preference else [],
[item["key"] for item in descriptors],
)
preference_data = {
"order": order,
"hidden": hidden,
"menus": descriptors,
}
return self.render(
"netbox_utilities/head.html",
{
"navigation_enabled": request.user.is_authenticated and navigation_customization_enabled(),
"navigation_preferences": preference_data,
},
)
def navbar(self):
request = self.context["request"]
if not request.user.is_authenticated or not tenant_filter_enabled():
return ""
tenants = Tenant.objects.restrict(request.user, "view").order_by("name")
return self.render(
"netbox_utilities/tenant_dropdown.html",
{
"tenants": tenants,
"selected_tenant": getattr(request, "netbox_utilities_tenant", None),
"return_url": request.get_full_path(),
},
)
template_extensions = [UtilitiesGlobalContent]
@@ -0,0 +1,6 @@
{% load static %}
<link rel="stylesheet" href="{% static 'netbox_utilities/netbox_utilities.css' %}">
{% if navigation_enabled %}
{{ navigation_preferences|json_script:"netbox-utilities-navigation-data" }}
<script src="{% static 'netbox_utilities/navigation.js' %}" defer></script>
{% endif %}
@@ -0,0 +1,88 @@
{% extends 'base/layout.html' %}
{% block title %}Navigation personalisieren{% endblock %}
{% block header %}
<div class="container-fluid mt-3">
<div class="d-flex justify-content-between align-items-center">
<h1>Navigation personalisieren</h1>
</div>
</div>
{% endblock header %}
{% block content %}
<div class="row justify-content-center">
<div class="col col-md-10 col-xl-8">
<div class="card">
<div class="card-header"><h2 class="card-title">Linke Navigation</h2></div>
<div class="card-body">
{% if not feature_enabled %}
<div class="alert alert-warning mb-0">
Die Navigationspersonalisierung wurde durch die NetBox-Konfiguration deaktiviert.
</div>
{% else %}
<p class="text-secondary">
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 %}
<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">
<input type="hidden" name="menu_order" value="{{ menu.key }}">
<i class="{{ menu.icon_class }} fs-2" aria-hidden="true"></i>
<span class="flex-fill fw-medium">{{ menu.label }}</span>
<label class="form-check form-switch mb-0" title="Menü ausblenden">
<input
class="form-check-input"
type="checkbox"
name="hidden_menus"
value="{{ menu.key }}"
{% if menu.hidden %}checked{% endif %}
>
<span class="form-check-label">Ausblenden</span>
</label>
<div class="btn-group" role="group" aria-label="Reihenfolge ändern">
<button class="btn btn-sm btn-outline-secondary move-up" type="button" title="Nach oben">
<i class="mdi mdi-arrow-up" aria-hidden="true"></i>
</button>
<button class="btn btn-sm btn-outline-secondary move-down" type="button" title="Nach unten">
<i class="mdi mdi-arrow-down" aria-hidden="true"></i>
</button>
</div>
</div>
{% empty %}
<div class="alert alert-info mb-0">Für diesen Benutzer sind keine Menüs verfügbar.</div>
{% endfor %}
</div>
<div class="d-flex justify-content-between">
<button class="btn btn-outline-danger" type="submit" name="reset" value="1">
Zurücksetzen
</button>
<button class="btn btn-primary" type="submit">Speichern</button>
</div>
</form>
{% endif %}
</div>
</div>
</div>
</div>
<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);
}
});
})();
</script>
{% endblock content %}
@@ -0,0 +1,38 @@
{% extends 'base/layout.html' %}
{% block title %}NetBox Utilities Einstellungen{% endblock %}
{% block header %}
<div class="container-fluid mt-3"><h1>NetBox Utilities Einstellungen</h1></div>
{% endblock header %}
{% block content %}
<div class="row justify-content-center">
<div class="col col-md-9 col-xl-7">
<div class="card">
<div class="card-header"><h2 class="card-title">Mandantenfilter</h2></div>
<div class="card-body">
{% if config_forces_disabled %}
<div class="alert alert-warning">
Der Mandantenfilter ist in <code>PLUGINS_CONFIG</code> fest deaktiviert und kann hier nicht aktiviert werden.
</div>
{% endif %}
<form method="post">
{% csrf_token %}
{% if form.non_field_errors %}<div class="alert alert-danger">{{ form.non_field_errors }}</div>{% endif %}
<div class="form-check form-switch">
{{ form.tenant_filter_enabled }}
<label class="form-check-label fw-bold" for="{{ form.tenant_filter_enabled.id_for_label }}">
{{ form.tenant_filter_enabled.label }}
</label>
</div>
<div class="form-text mb-3">{{ form.tenant_filter_enabled.help_text }}</div>
<button class="btn btn-primary" type="submit"{% if config_forces_disabled %} disabled{% endif %}>
Speichern
</button>
</form>
</div>
</div>
</div>
</div>
{% endblock content %}
@@ -0,0 +1,44 @@
<div class="nav-item dropdown netbox-utilities-tenant">
<button
class="nav-link px-2"
type="button"
data-bs-toggle="dropdown"
aria-expanded="false"
title="Globaler Mandantenfilter"
aria-label="Globaler Mandantenfilter"
>
<i class="mdi mdi-domain" aria-hidden="true"></i>
<span class="d-none d-xl-inline ms-1 tenant-filter-label">
{% if selected_tenant %}{{ selected_tenant }}{% else %}Alle Mandanten{% endif %}
</span>
<i class="mdi mdi-chevron-down" aria-hidden="true"></i>
</button>
<div class="dropdown-menu dropdown-menu-end p-3 tenant-filter-menu">
<form action="{% url 'plugins:netbox_utilities:select_tenant' %}" method="post">
{% csrf_token %}
<input type="hidden" name="next" value="{{ return_url }}">
<label class="form-label fw-bold">
Mandant
</label>
<select
class="form-select"
name="tenant"
onchange="this.form.submit()"
aria-label="Mandant global auswählen"
>
<option value="">Alle Mandanten</option>
{% for tenant in tenants %}
<option value="{{ tenant.pk }}"{% if selected_tenant.pk == tenant.pk %} selected{% endif %}>
{{ tenant.name }}
</option>
{% endfor %}
</select>
<noscript>
<button class="btn btn-primary w-100 mt-2" type="submit">Anwenden</button>
</noscript>
<p class="text-secondary small mb-0 mt-2">
Wirkt auf mandantenfähige Listen und die globale Suche.
</p>
</form>
</div>
</div>
+72
View File
@@ -0,0 +1,72 @@
from contextvars import ContextVar
from functools import wraps
from tenancy.models import Tenant
active_tenant_id = ContextVar("netbox_utilities_active_tenant_id", default=None)
_PARENT_RELATIONS = (
"device",
"virtual_machine",
"site",
"location",
"rack",
"cluster",
"circuit",
"virtual_circuit",
"tunnel",
"l2vpn",
"wireless_lan",
"power_panel",
)
def object_matches_tenant(obj, tenant_id, depth=0):
"""
Return whether a search result belongs to the selected tenant.
Models without tenant semantics are shared reference data and remain visible.
Child objects inherit tenant membership from a known parent relationship.
"""
if obj is None:
return False
if isinstance(obj, Tenant):
return obj.pk == tenant_id
field_names = {field.name for field in obj._meta.get_fields()}
if "tenant" in field_names:
return getattr(obj, "tenant_id", None) == tenant_id
if depth < 2:
for relation in _PARENT_RELATIONS:
if relation not in field_names:
continue
parent = getattr(obj, relation, None)
if parent is None:
return False
return object_matches_tenant(parent, tenant_id, depth + 1)
# Global reference objects (roles, manufacturers, statuses, etc.) are not
# tenant-owned and must remain usable while a filter is active.
return True
def install_search_filter():
"""Install the tenant scope around NetBox's singleton global search backend."""
from netbox.search.backends import search_backend
if getattr(search_backend, "_netbox_utilities_wrapped", False):
return
original_search = search_backend.search
@wraps(original_search)
def tenant_aware_search(*args, **kwargs):
results = original_search(*args, **kwargs)
tenant_id = active_tenant_id.get()
if tenant_id is None:
return results
return [result for result in results if object_matches_tenant(result.object, tenant_id)]
search_backend.search = tenant_aware_search
search_backend._netbox_utilities_wrapped = True
+1
View File
@@ -0,0 +1 @@
+57
View File
@@ -0,0 +1,57 @@
from types import SimpleNamespace
from unittest.mock import patch
from django.test import RequestFactory, SimpleTestCase
from netbox_utilities.middleware import GlobalTenantFilterMiddleware
class TenantFilterSet:
base_filters = {"tenant_id": object()}
class SharedFilterSet:
base_filters = {"status": object()}
def resolver_match(filterset=None, view_name="dcim:device_list"):
view_class = SimpleNamespace(filterset=filterset)
return SimpleNamespace(
func=SimpleNamespace(view_class=view_class),
namespaces=["dcim"],
view_name=view_name,
)
class GlobalTenantFilterMiddlewareTest(SimpleTestCase):
def setUp(self):
self.factory = RequestFactory()
@patch("netbox_utilities.middleware.resolve")
def test_injects_and_overrides_tenant_id(self, mocked_resolve):
mocked_resolve.return_value = resolver_match(TenantFilterSet)
request = self.factory.get("/dcim/devices/?tenant_id=7&status=active")
GlobalTenantFilterMiddleware._inject_filter_parameter(request, 42)
self.assertEqual(request.GET.getlist("tenant_id"), ["42"])
self.assertEqual(request.GET["status"], "active")
@patch("netbox_utilities.middleware.resolve")
def test_does_not_modify_shared_list(self, mocked_resolve):
mocked_resolve.return_value = resolver_match(SharedFilterSet)
request = self.factory.get("/dcim/manufacturers/?status=active")
GlobalTenantFilterMiddleware._inject_filter_parameter(request, 42)
self.assertNotIn("tenant_id", request.GET)
self.assertEqual(request.GET["status"], "active")
@patch("netbox_utilities.middleware.resolve")
def test_filters_tenant_list_by_primary_key(self, mocked_resolve):
mocked_resolve.return_value = resolver_match(None, "tenancy:tenant_list")
request = self.factory.get("/tenancy/tenants/")
GlobalTenantFilterMiddleware._inject_filter_parameter(request, 42)
self.assertEqual(request.GET.getlist("id"), ["42"])
@@ -0,0 +1,21 @@
from django.test import SimpleTestCase
from netbox_utilities.navigation_helpers import normalize_preferences
class NormalizePreferencesTest(SimpleTestCase):
def test_removes_unknown_and_duplicate_keys(self):
order, hidden = normalize_preferences(
["devices", "unknown", "devices"],
["ipam", "unknown", "ipam"],
["organization", "devices", "ipam"],
)
self.assertEqual(order, ["devices", "organization", "ipam"])
self.assertEqual(hidden, ["ipam"])
def test_new_menus_are_appended(self):
order, hidden = normalize_preferences(["devices"], [], ["devices", "vpn"])
self.assertEqual(order, ["devices", "vpn"])
self.assertEqual(hidden, [])
@@ -0,0 +1,34 @@
from types import SimpleNamespace
from django.test import SimpleTestCase
from netbox_utilities.tenant_scope import object_matches_tenant
class FakeMeta:
def __init__(self, *field_names):
self.fields = [SimpleNamespace(name=name) for name in field_names]
def get_fields(self):
return self.fields
class TenantScopeTest(SimpleTestCase):
def test_direct_tenant_match(self):
obj = SimpleNamespace(_meta=FakeMeta("tenant"), tenant_id=42)
self.assertTrue(object_matches_tenant(obj, 42))
self.assertFalse(object_matches_tenant(obj, 7))
def test_child_inherits_tenant_from_device(self):
device = SimpleNamespace(_meta=FakeMeta("tenant"), tenant_id=42)
interface = SimpleNamespace(_meta=FakeMeta("device"), device=device)
self.assertTrue(object_matches_tenant(interface, 42))
self.assertFalse(object_matches_tenant(interface, 7))
def test_shared_reference_data_remains_visible(self):
manufacturer = SimpleNamespace(_meta=FakeMeta("name"))
self.assertTrue(object_matches_tenant(manufacturer, 42))
def test_unassigned_tenant_object_is_hidden(self):
obj = SimpleNamespace(_meta=FakeMeta("tenant"), tenant_id=None)
self.assertFalse(object_matches_tenant(obj, 42))
+11
View File
@@ -0,0 +1,11 @@
from django.urls import path
from . import views
app_name = "netbox_utilities"
urlpatterns = [
path("navigation/", views.NavigationPreferencesView.as_view(), name="navigation_preferences"),
path("settings/", views.UtilitiesSettingsView.as_view(), name="settings"),
path("tenant/select/", views.SelectTenantView.as_view(), name="select_tenant"),
]
+132
View File
@@ -0,0 +1,132 @@
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
from django.http import HttpResponseBadRequest
from django.shortcuts import redirect, render
from django.urls import reverse
from django.utils.http import url_has_allowed_host_and_scheme
from django.views import View
from netbox.plugins import get_plugin_config
from tenancy.models import Tenant
from .forms import UtilitiesSettingsForm
from .middleware import SESSION_TENANT_KEY
from .models import NavigationPreference, UtilitiesSettings
from .navigation_helpers import get_visible_menus, normalize_preferences
from .runtime import (
clear_runtime_settings_cache,
navigation_customization_enabled,
tenant_filter_enabled,
)
def _safe_return_url(request, default_name="home"):
candidate = request.POST.get("next") or request.GET.get("next")
if candidate and url_has_allowed_host_and_scheme(
candidate,
allowed_hosts={request.get_host()},
require_https=request.is_secure(),
):
return candidate
return reverse(default_name)
class NavigationPreferencesView(LoginRequiredMixin, View):
template_name = "netbox_utilities/navigation_preferences.html"
def get(self, request):
return self._render(request)
def post(self, request):
if not navigation_customization_enabled():
return HttpResponseBadRequest("Die Navigationspersonalisierung ist deaktiviert.")
preference, _ = NavigationPreference.objects.get_or_create(user=request.user)
if "reset" in request.POST:
preference.menu_order = []
preference.hidden_menus = []
else:
descriptors = get_visible_menus(request.user)
available_keys = [item["key"] for item in descriptors]
order, hidden = normalize_preferences(
request.POST.getlist("menu_order"),
request.POST.getlist("hidden_menus"),
available_keys,
)
preference.menu_order = order
preference.hidden_menus = hidden
preference.save()
messages.success(request, "Die persönliche Navigation wurde gespeichert.")
return redirect("plugins:netbox_utilities:navigation_preferences")
def _render(self, request):
descriptors = get_visible_menus(request.user)
preference = NavigationPreference.objects.filter(user=request.user).first()
order, hidden = normalize_preferences(
preference.menu_order if preference else [],
preference.hidden_menus if preference else [],
[item["key"] for item in descriptors],
)
by_key = {item["key"]: item for item in descriptors}
menus = [{**by_key[key], "hidden": key in hidden} for key in order]
return render(
request,
self.template_name,
{
"menus": menus,
"feature_enabled": navigation_customization_enabled(),
},
)
class UtilitiesSettingsView(LoginRequiredMixin, UserPassesTestMixin, View):
template_name = "netbox_utilities/settings.html"
raise_exception = True
def test_func(self):
return self.request.user.is_superuser
def get(self, request):
instance, _ = UtilitiesSettings.objects.get_or_create(singleton_id=1)
return render(
request,
self.template_name,
{
"form": UtilitiesSettingsForm(instance=instance),
"config_forces_disabled": not get_plugin_config("netbox_utilities", "tenant_filter_enabled"),
},
)
def post(self, request):
if not get_plugin_config("netbox_utilities", "tenant_filter_enabled"):
return HttpResponseBadRequest("Der Mandantenfilter ist in PLUGINS_CONFIG deaktiviert.")
instance, _ = UtilitiesSettings.objects.get_or_create(singleton_id=1)
form = UtilitiesSettingsForm(request.POST, instance=instance)
if form.is_valid():
form.save()
clear_runtime_settings_cache()
if not form.cleaned_data["tenant_filter_enabled"]:
request.session.pop(SESSION_TENANT_KEY, None)
messages.success(request, "Die Plugin-Einstellungen wurden gespeichert.")
return redirect("plugins:netbox_utilities:settings")
return render(request, self.template_name, {"form": form})
class SelectTenantView(LoginRequiredMixin, View):
http_method_names = ["post"]
def post(self, request):
if not tenant_filter_enabled():
return HttpResponseBadRequest("Der globale Mandantenfilter ist deaktiviert.")
tenant_id = request.POST.get("tenant")
if tenant_id:
tenant = Tenant.objects.restrict(request.user, "view").filter(pk=tenant_id).first()
if tenant is None:
return HttpResponseBadRequest("Unbekannter oder nicht erlaubter Mandant.")
request.session[SESSION_TENANT_KEY] = tenant.pk
messages.success(request, f"Globaler Mandantenfilter: {tenant}")
else:
request.session.pop(SESSION_TENANT_KEY, None)
messages.success(request, "Der globale Mandantenfilter wurde aufgehoben.")
return redirect(_safe_return_url(request))