commit 54f6a3f6c9a7e43f7eeae3bcf65fdbd671329fb4 Author: Louis Date: Tue Jul 28 16:40:50 2026 +0200 feat: add personalized navigation and tenant filtering diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..583cdf0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +__pycache__/ +*.py[cod] +.pytest_cache/ +.ruff_cache/ +build/ +dist/ +*.egg-info/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..929b803 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..c34eaf3 --- /dev/null +++ b/README.md @@ -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 +``` diff --git a/netbox_utilities/__init__.py b/netbox_utilities/__init__.py new file mode 100644 index 0000000..c470393 --- /dev/null +++ b/netbox_utilities/__init__.py @@ -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 diff --git a/netbox_utilities/forms.py b/netbox_utilities/forms.py new file mode 100644 index 0000000..a2ce422 --- /dev/null +++ b/netbox_utilities/forms.py @@ -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"}), + } diff --git a/netbox_utilities/middleware.py b/netbox_utilities/middleware.py new file mode 100644 index 0000000..c47fedc --- /dev/null +++ b/netbox_utilities/middleware.py @@ -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 diff --git a/netbox_utilities/migrations/0001_initial.py b/netbox_utilities/migrations/0001_initial.py new file mode 100644 index 0000000..af934bd --- /dev/null +++ b/netbox_utilities/migrations/0001_initial.py @@ -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",)}, + ), + ] diff --git a/netbox_utilities/migrations/__init__.py b/netbox_utilities/migrations/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/netbox_utilities/migrations/__init__.py @@ -0,0 +1 @@ + diff --git a/netbox_utilities/models.py b/netbox_utilities/models.py new file mode 100644 index 0000000..4bb29c4 --- /dev/null +++ b/netbox_utilities/models.py @@ -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" diff --git a/netbox_utilities/navigation.py b/netbox_utilities/navigation.py new file mode 100644 index 0000000..9665579 --- /dev/null +++ b/netbox_utilities/navigation.py @@ -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, + ), +) diff --git a/netbox_utilities/navigation_helpers.py b/netbox_utilities/navigation_helpers.py new file mode 100644 index 0000000..9eebdea --- /dev/null +++ b/netbox_utilities/navigation_helpers.py @@ -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 diff --git a/netbox_utilities/runtime.py b/netbox_utilities/runtime.py new file mode 100644 index 0000000..676e70c --- /dev/null +++ b/netbox_utilities/runtime.py @@ -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) diff --git a/netbox_utilities/static/netbox_utilities/navigation.js b/netbox_utilities/static/netbox_utilities/navigation.js new file mode 100644 index 0000000..94b82f9 --- /dev/null +++ b/netbox_utilities/static/netbox_utilities/navigation.js @@ -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(); + } +})(); diff --git a/netbox_utilities/static/netbox_utilities/netbox_utilities.css b/netbox_utilities/static/netbox_utilities/netbox_utilities.css new file mode 100644 index 0000000..471116e --- /dev/null +++ b/netbox_utilities/static/netbox_utilities/netbox_utilities.css @@ -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; +} diff --git a/netbox_utilities/template_content.py b/netbox_utilities/template_content.py new file mode 100644 index 0000000..f3c34fe --- /dev/null +++ b/netbox_utilities/template_content.py @@ -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] diff --git a/netbox_utilities/templates/netbox_utilities/head.html b/netbox_utilities/templates/netbox_utilities/head.html new file mode 100644 index 0000000..80441ea --- /dev/null +++ b/netbox_utilities/templates/netbox_utilities/head.html @@ -0,0 +1,6 @@ +{% load static %} + +{% if navigation_enabled %} + {{ navigation_preferences|json_script:"netbox-utilities-navigation-data" }} + +{% endif %} diff --git a/netbox_utilities/templates/netbox_utilities/navigation_preferences.html b/netbox_utilities/templates/netbox_utilities/navigation_preferences.html new file mode 100644 index 0000000..f97e2eb --- /dev/null +++ b/netbox_utilities/templates/netbox_utilities/navigation_preferences.html @@ -0,0 +1,88 @@ +{% extends 'base/layout.html' %} + +{% block title %}Navigation personalisieren{% endblock %} + +{% block header %} +
+
+

Navigation personalisieren

+
+
+{% endblock header %} + +{% block content %} +
+
+
+

Linke Navigation

+
+ {% if not feature_enabled %} +
+ Die Navigationspersonalisierung wurde durch die NetBox-Konfiguration deaktiviert. +
+ {% else %} +

+ Verschiebe Menüs mit den Pfeilen und blende nicht benötigte Bereiche aus. + Berechtigungen von NetBox werden dadurch nicht verändert. +

+ + {% endif %} +
+
+
+
+ +{% endblock content %} diff --git a/netbox_utilities/templates/netbox_utilities/settings.html b/netbox_utilities/templates/netbox_utilities/settings.html new file mode 100644 index 0000000..e2c1160 --- /dev/null +++ b/netbox_utilities/templates/netbox_utilities/settings.html @@ -0,0 +1,38 @@ +{% extends 'base/layout.html' %} + +{% block title %}NetBox Utilities – Einstellungen{% endblock %} + +{% block header %} +

NetBox Utilities – Einstellungen

+{% endblock header %} + +{% block content %} +
+
+
+

Mandantenfilter

+
+ {% if config_forces_disabled %} +
+ Der Mandantenfilter ist in PLUGINS_CONFIG fest deaktiviert und kann hier nicht aktiviert werden. +
+ {% endif %} +
+ {% csrf_token %} + {% if form.non_field_errors %}
{{ form.non_field_errors }}
{% endif %} +
+ {{ form.tenant_filter_enabled }} + +
+
{{ form.tenant_filter_enabled.help_text }}
+ +
+
+
+
+
+{% endblock content %} diff --git a/netbox_utilities/templates/netbox_utilities/tenant_dropdown.html b/netbox_utilities/templates/netbox_utilities/tenant_dropdown.html new file mode 100644 index 0000000..acc15ac --- /dev/null +++ b/netbox_utilities/templates/netbox_utilities/tenant_dropdown.html @@ -0,0 +1,44 @@ + diff --git a/netbox_utilities/tenant_scope.py b/netbox_utilities/tenant_scope.py new file mode 100644 index 0000000..117815d --- /dev/null +++ b/netbox_utilities/tenant_scope.py @@ -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 diff --git a/netbox_utilities/tests/__init__.py b/netbox_utilities/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/netbox_utilities/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/netbox_utilities/tests/test_middleware.py b/netbox_utilities/tests/test_middleware.py new file mode 100644 index 0000000..bc57232 --- /dev/null +++ b/netbox_utilities/tests/test_middleware.py @@ -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"]) diff --git a/netbox_utilities/tests/test_navigation_helpers.py b/netbox_utilities/tests/test_navigation_helpers.py new file mode 100644 index 0000000..ec255b1 --- /dev/null +++ b/netbox_utilities/tests/test_navigation_helpers.py @@ -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, []) diff --git a/netbox_utilities/tests/test_tenant_scope.py b/netbox_utilities/tests/test_tenant_scope.py new file mode 100644 index 0000000..a1b5f82 --- /dev/null +++ b/netbox_utilities/tests/test_tenant_scope.py @@ -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)) diff --git a/netbox_utilities/urls.py b/netbox_utilities/urls.py new file mode 100644 index 0000000..f10b6c0 --- /dev/null +++ b/netbox_utilities/urls.py @@ -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"), +] diff --git a/netbox_utilities/views.py b/netbox_utilities/views.py new file mode 100644 index 0000000..5cc9043 --- /dev/null +++ b/netbox_utilities/views.py @@ -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)) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..1cbb4cf --- /dev/null +++ b/pyproject.toml @@ -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"]