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
+7
View File
@@ -0,0 +1,7 @@
__pycache__/
*.py[cod]
.pytest_cache/
.ruff_cache/
build/
dist/
*.egg-info/
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 MrBlake
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+161
View File
@@ -0,0 +1,161 @@
# NetBox Utilities
Plugin für **NetBox 4.6.5** mit zwei Funktionen:
- Jeder Benutzer kann die Menüs der linken Navigation verschieben oder ausblenden.
- Ein Dropdown in der Kopfleiste setzt einen sitzungsweiten Mandantenfilter für mandantenfähige Listen und die globale Suche.
Die Navigationseinstellungen sind benutzerbezogen. Der aktive Mandant wird in der jeweiligen Browser-Session gespeichert.
## Kompatibilität
- NetBox `>=4.6.5,<4.7`
- Python `>=3.12`
Andere NetBox-Versionen werden vom Plugin absichtlich abgelehnt, da die Anpassung der Core-Navigation von deren HTML-Struktur abhängt.
## Installation aus Gitea
Das Plugin kann direkt aus dem `main`-Branch installiert werden:
```bash
/opt/netbox/venv/bin/pip install --upgrade --force-reinstall \
"git+https://git.mrblake.cc/MrBlake/Netbox-Utilities.git@main"
```
Für eine reproduzierbare Produktivinstallation sollte statt `main` ein
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.1.0"
```
Alternativ kann hinter dem `@` die vollständige Commit-ID stehen.
Damit das Plugin bei zukünftigen NetBox-Upgrades automatisch erneut
installiert wird, wird derselbe Eintrag in `/opt/netbox/local_requirements.txt`
hinterlegt:
```text
git+https://git.mrblake.cc/MrBlake/Netbox-Utilities.git@main
```
Bei einem privaten Gitea-Repository benötigt der NetBox-Server einen
Deploy-Token oder einen SSH-Key. Für SSH lautet der Eintrag beispielsweise:
```text
git+ssh://git@git.mrblake.cc/MrBlake/Netbox-Utilities.git@main
```
### NetBox-Konfiguration
Das Plugin wird in `/opt/netbox/netbox/netbox/configuration.py` aktiviert:
```python
PLUGINS = [
"netbox_utilities",
]
PLUGINS_CONFIG = {
"netbox_utilities": {
"navigation_customization_enabled": True,
"tenant_filter_enabled": True,
},
}
```
Wenn bereits andere Plugins eingetragen sind, wird `netbox_utilities` zu den
bestehenden Listen beziehungsweise Dictionaries hinzugefügt; deren Inhalt darf
nicht überschrieben werden.
### Installation abschließen
Migrationen und statische Dateien werden nach der Installation verarbeitet:
```bash
cd /opt/netbox/netbox
/opt/netbox/venv/bin/python manage.py migrate netbox_utilities
/opt/netbox/venv/bin/python manage.py collectstatic --no-input
sudo systemctl restart netbox netbox-rq
```
Alternativ erledigt das NetBox-Upgrade-Skript die Installation aus
`local_requirements.txt`, Migrationen und `collectstatic` gemeinsam:
```bash
sudo /opt/netbox/upgrade.sh
sudo systemctl restart netbox netbox-rq
```
Nach Änderungen an JavaScript oder CSS kann ein Hard-Reload des Browsers mit
`Strg+F5` erforderlich sein.
## Aktualisierung
Bei einer Installation aus dem `main`-Branch wird das Paket erneut aus Gitea
installiert und anschließend NetBox aktualisiert:
```bash
/opt/netbox/venv/bin/pip install --upgrade --force-reinstall \
"git+https://git.mrblake.cc/MrBlake/Netbox-Utilities.git@main"
cd /opt/netbox/netbox
/opt/netbox/venv/bin/python manage.py migrate netbox_utilities
/opt/netbox/venv/bin/python manage.py collectstatic --no-input
sudo systemctl restart netbox netbox-rq
```
`--force-reinstall` stellt sicher, dass Änderungen aus einem Branch auch dann
installiert werden, wenn die interne Paketversion noch nicht erhöht wurde.
Wird ein Release-Tag in `/opt/netbox/local_requirements.txt` verwendet, muss
dieser Eintrag vor dem Update auf den neuen Tag geändert werden. Danach genügt:
```bash
sudo /opt/netbox/upgrade.sh
sudo systemctl restart netbox netbox-rq
```
Vor Produktivupdates sollte der neue Stand in einer Testumgebung geprüft
werden. Ein Backup der NetBox-Datenbank bleibt unabhängig vom Plugin dringend
empfohlen.
## Verwendung
### 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.
Die Funktion kann installationsweit über `navigation_customization_enabled = False` in `PLUGINS_CONFIG` abgeschaltet werden.
### Globaler Mandantenfilter
Das Gebäude-Symbol in der Kopfleiste öffnet die Mandantenauswahl. Zur Auswahl stehen nur Mandanten, die der angemeldete Benutzer gemäß NetBox-Objektberechtigungen sehen darf. **Alle Mandanten** hebt den Filter auf.
Der Filter:
- erzwingt `tenant_id` in allen NetBox-Listen, deren FilterSet dieses Feld unterstützt;
- beschränkt die Mandantenliste auf den gewählten Mandanten;
- beschränkt Ergebnisse der globalen NetBox-Suche;
- gilt auch für HTMX-Tabellenupdates und Exporte aus einer gefilterten Liste;
- verändert keine REST- oder GraphQL-Anfrage.
Superuser können die Funktion unter **Plugins > NetBox Utilities > Einstellungen** zur Laufzeit deaktivieren. Zusätzlich kann `tenant_filter_enabled = False` in `PLUGINS_CONFIG` sie hart abschalten; diese Konfiguration hat Vorrang vor der Einstellung in der Oberfläche.
## Wichtige Semantik
Der Mandantenfilter ist ein **Ansichtsfilter und keine Zugriffskontrolle**. Direkte Objekt-URLs werden nicht gesperrt. NetBox-Objektberechtigungen bleiben die maßgebliche Sicherheitsgrenze.
Nur Modelle mit einer Mandantenzuordnung werden eingeschränkt. Globale Referenzdaten wie Hersteller, Rollen oder Plattformen bleiben sichtbar, weil sie keinem Mandanten gehören und für die Darstellung bzw. Bearbeitung mandantengebundener Objekte benötigt werden.
Die Seitenleisten-Personalisierung nutzt NetBox' offizielle globale `PluginTemplateExtension`, ordnet aber bereits gerenderte Core-Menüs im Browser neu. Sie ändert weder Core-Templates noch NetBox-Dateien.
## Entwicklung und Tests
In einer NetBox-4.6.5-Entwicklungsumgebung:
```bash
pip install -e /pfad/zu/Netbox-Utilities
python netbox/manage.py test netbox_utilities
```
+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))
+33
View File
@@ -0,0 +1,33 @@
[build-system]
requires = ["hatchling>=1.27"]
build-backend = "hatchling.build"
[project]
name = "netbox-utilities"
version = "0.1.0"
description = "Personal navigation and a global tenant filter for NetBox 4.6"
readme = "README.md"
requires-python = ">=3.12"
license = { text = "MIT" }
authors = [
{ name = "MrBlake" }
]
classifiers = [
"Framework :: Django",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
]
[project.urls]
Repository = "https://git.mrblake.cc/MrBlake/Netbox-Utilities"
[tool.hatch.build.targets.wheel]
packages = ["netbox_utilities"]
[tool.ruff]
line-length = 120
target-version = "py312"
[tool.ruff.lint]
ignore = ["RUF012"]