diff --git a/README.md b/README.md
index c34eaf3..e7e9ad5 100644
--- a/README.md
+++ b/README.md
@@ -1,11 +1,12 @@
# NetBox Utilities
-Plugin für **NetBox 4.6.5** mit zwei Funktionen:
+Plugin für **NetBox 4.6.5** mit drei 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.
+- Ein Dropdown in der Kopfleiste setzt einen sitzungsweiten Filter für einen Mandanten oder eine Mandantengruppe.
+- Optional verpflichtende Mandantenzuordnung für alle mandantenfähigen Objekte.
-Die Navigationseinstellungen sind benutzerbezogen. Der aktive Mandant wird in der jeweiligen Browser-Session gespeichert.
+Die Navigationseinstellungen sind benutzerbezogen. Die aktive Mandanten- oder Gruppenauswahl wird in der jeweiligen Browser-Session gespeichert.
## Kompatibilität
@@ -28,7 +29,7 @@ Release-Tag oder ein bestimmter Commit verwendet werden:
```bash
/opt/netbox/venv/bin/pip install --upgrade --force-reinstall \
- "git+https://git.mrblake.cc/MrBlake/Netbox-Utilities.git@v0.1.0"
+ "git+https://git.mrblake.cc/MrBlake/Netbox-Utilities.git@v0.2.0"
```
Alternativ kann hinter dem `@` die vollständige Commit-ID stehen.
@@ -61,6 +62,7 @@ PLUGINS_CONFIG = {
"netbox_utilities": {
"navigation_customization_enabled": True,
"tenant_filter_enabled": True,
+ "tenant_required": True,
},
}
```
@@ -129,23 +131,42 @@ Unter **Plugins > NetBox Utilities > Navigation personalisieren** sieht der Benu
Die Funktion kann installationsweit über `navigation_customization_enabled = False` in `PLUGINS_CONFIG` abgeschaltet werden.
-### Globaler Mandantenfilter
+### Globaler Mandanten- und Gruppenfilter
-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.
+Das Gebäude-Symbol in der Kopfleiste öffnet die Auswahl. Zur Auswahl stehen
+Mandanten und Mandantengruppen, die der angemeldete Benutzer gemäß
+NetBox-Objektberechtigungen sehen darf. Eine Mandantengruppe umfasst auch die
+Mandanten ihrer untergeordneten Gruppen. **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;
+- erzwingt je nach Auswahl `tenant_id` oder `tenant_group_id` in unterstützten NetBox-Listen;
+- beschränkt die Mandantenliste auf den gewählten Mandanten beziehungsweise die gewählte Gruppe;
- 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.
+### Verpflichtende Mandantenzuordnung
+
+Standardmäßig ist unter **Plugins > NetBox Utilities > Einstellungen** die
+verpflichtende Mandantenzuordnung aktiviert. Bei allen Objekttypen, die in
+NetBox ein `tenant`-Feld besitzen, wird dieses Feld als Pflichtfeld behandelt.
+Die Prüfung greift in Formularen, CSV-Importen, der REST-API und bei normalen
+Aufrufen von `Model.save()` aus Skripten.
+
+Bestehende Objekte ohne Mandant bleiben nach Aktivierung zunächst bestehen.
+Beim nächsten Speichern eines solchen Objekts muss ein Mandant ergänzt werden.
+Globale Referenzmodelle ohne `tenant`-Feld sind nicht betroffen.
+
+Die Einstellung kann durch einen Superuser deaktiviert werden. Mit
+`tenant_required = False` in `PLUGINS_CONFIG` wird sie installationsweit fest
+deaktiviert; diese Konfiguration hat Vorrang vor der Admin-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.
+Der Mandantenfilter ist ein **Ansichtsfilter und keine Zugriffskontrolle**. Direkte Objekt-URLs werden nicht gesperrt. NetBox-Objektberechtigungen bleiben die maßgebliche Sicherheitsgrenze. Die verpflichtende Mandantenzuordnung ist dagegen eine serverseitige Datenvalidierung.
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.
diff --git a/netbox_utilities/__init__.py b/netbox_utilities/__init__.py
index c470393..2ed8bca 100644
--- a/netbox_utilities/__init__.py
+++ b/netbox_utilities/__init__.py
@@ -5,7 +5,7 @@ class NetBoxUtilitiesConfig(PluginConfig):
name = "netbox_utilities"
verbose_name = "NetBox Utilities"
description = "Personal navigation and a global tenant filter"
- version = "0.1.0"
+ version = "0.2.0"
author = "LKE"
base_url = "utilities"
min_version = "4.6.5"
@@ -13,6 +13,7 @@ class NetBoxUtilitiesConfig(PluginConfig):
default_settings = {
"navigation_customization_enabled": True,
"tenant_filter_enabled": True,
+ "tenant_required": True,
}
middleware = [
"netbox_utilities.middleware.GlobalTenantFilterMiddleware",
@@ -21,8 +22,10 @@ class NetBoxUtilitiesConfig(PluginConfig):
def ready(self):
super().ready()
from .tenant_scope import install_search_filter
+ from .tenant_validation import install_tenant_validation
install_search_filter()
+ install_tenant_validation()
config = NetBoxUtilitiesConfig
diff --git a/netbox_utilities/forms.py b/netbox_utilities/forms.py
index a2ce422..dd0dc9a 100644
--- a/netbox_utilities/forms.py
+++ b/netbox_utilities/forms.py
@@ -4,18 +4,29 @@ from .models import UtilitiesSettings
class UtilitiesSettingsForm(forms.ModelForm):
+ def __init__(self, *args, tenant_filter_locked=False, tenant_required_locked=False, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.fields["tenant_filter_enabled"].disabled = tenant_filter_locked
+ self.fields["tenant_required"].disabled = tenant_required_locked
+
class Meta:
model = UtilitiesSettings
- fields = ("tenant_filter_enabled",)
+ fields = ("tenant_filter_enabled", "tenant_required")
labels = {
"tenant_filter_enabled": "Globalen Mandantenfilter aktivieren",
+ "tenant_required": "Mandant für mandantenfähige Objekte verpflichtend machen",
}
help_texts = {
"tenant_filter_enabled": (
"Blendet das Mandanten-Dropdown ein und ergänzt unterstützte NetBox-Listen "
"automatisch um den gewählten Mandantenfilter."
),
+ "tenant_required": (
+ "Verhindert das Speichern mandantenfähiger Objekte ohne Mandant. "
+ "Dies gilt auch für Importe, API-Aufrufe und Skripte."
+ ),
}
widgets = {
"tenant_filter_enabled": forms.CheckboxInput(attrs={"class": "form-check-input"}),
+ "tenant_required": forms.CheckboxInput(attrs={"class": "form-check-input"}),
}
diff --git a/netbox_utilities/middleware.py b/netbox_utilities/middleware.py
index c47fedc..4e6199a 100644
--- a/netbox_utilities/middleware.py
+++ b/netbox_utilities/middleware.py
@@ -1,10 +1,11 @@
from django.urls import Resolver404, resolve
-from tenancy.models import Tenant
+from tenancy.models import Tenant, TenantGroup
from .runtime import tenant_filter_enabled
-from .tenant_scope import active_tenant_id
+from .tenant_scope import ActiveTenantScope, active_tenant_scope
SESSION_TENANT_KEY = "netbox_utilities.selected_tenant_id"
+SESSION_TENANT_GROUP_KEY = "netbox_utilities.selected_tenant_group_id"
class GlobalTenantFilterMiddleware:
@@ -15,35 +16,53 @@ class GlobalTenantFilterMiddleware:
def __call__(self, request):
request.netbox_utilities_tenant = None
- tenant_id = self._get_selected_tenant_id(request)
- token = active_tenant_id.set(tenant_id)
+ request.netbox_utilities_tenant_group = None
+ scope = self._get_selected_scope(request)
+ token = active_tenant_scope.set(scope)
try:
- if tenant_id is not None and request.method in {"GET", "HEAD"}:
- self._inject_filter_parameter(request, tenant_id)
+ if scope is not None and request.method in {"GET", "HEAD"}:
+ self._inject_filter_parameter(request, scope)
return self.get_response(request)
finally:
- active_tenant_id.reset(token)
+ active_tenant_scope.reset(token)
@staticmethod
- def _get_selected_tenant_id(request):
+ def _get_selected_scope(request):
if not tenant_filter_enabled() or not request.user.is_authenticated:
- request.session.pop(SESSION_TENANT_KEY, None)
+ GlobalTenantFilterMiddleware.clear_scope(request)
return None
+ tenant_group_id = request.session.get(SESSION_TENANT_GROUP_KEY)
+ if tenant_group_id is not None:
+ group = TenantGroup.objects.restrict(request.user, "view").filter(pk=tenant_group_id).first()
+ if group is None:
+ GlobalTenantFilterMiddleware.clear_scope(request)
+ return None
+ descendant_groups = group.get_descendants(include_self=True)
+ group_ids = frozenset(descendant_groups.values_list("pk", flat=True))
+ tenant_ids = Tenant.objects.filter(group_id__in=group_ids).values_list("pk", flat=True)
+ request.netbox_utilities_tenant_group = group
+ return ActiveTenantScope("group", group.pk, frozenset(tenant_ids), group_ids)
+
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)
+ GlobalTenantFilterMiddleware.clear_scope(request)
return None
request.netbox_utilities_tenant = tenant
- return tenant.pk
+ return ActiveTenantScope("tenant", tenant.pk, frozenset({tenant.pk}))
@staticmethod
- def _inject_filter_parameter(request, tenant_id):
+ def clear_scope(request):
+ request.session.pop(SESSION_TENANT_KEY, None)
+ request.session.pop(SESSION_TENANT_GROUP_KEY, None)
+
+ @staticmethod
+ def _inject_filter_parameter(request, scope):
try:
match = resolve(request.path_info)
except Resolver404:
@@ -54,15 +73,32 @@ class GlobalTenantFilterMiddleware:
return
parameter = None
+ values = []
if match.view_name == "tenancy:tenant_list":
+ if scope.kind == "tenant":
+ parameter = "id"
+ values = [scope.object_id]
+ else:
+ parameter = "group_id"
+ values = [scope.object_id]
+ elif match.view_name == "tenancy:tenantgroup_list" and scope.kind == "group":
parameter = "id"
+ values = list(scope.group_ids) or [scope.object_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", {}):
+ base_filters = getattr(filterset, "base_filters", {}) if filterset else {}
+ if scope.kind == "tenant" and "tenant_id" in base_filters:
parameter = "tenant_id"
+ values = [scope.object_id]
+ elif scope.kind == "group" and "tenant_group_id" in base_filters:
+ parameter = "tenant_group_id"
+ values = [scope.object_id]
+ elif scope.kind == "group" and "tenant_id" in base_filters:
+ parameter = "tenant_id"
+ values = list(scope.tenant_ids) or [0]
if parameter:
query = request.GET.copy()
- query.setlist(parameter, [str(tenant_id)])
+ query.setlist(parameter, [str(value) for value in values])
request.GET = query
diff --git a/netbox_utilities/migrations/0002_utilitiessettings_tenant_required.py b/netbox_utilities/migrations/0002_utilitiessettings_tenant_required.py
new file mode 100644
index 0000000..33f60df
--- /dev/null
+++ b/netbox_utilities/migrations/0002_utilitiessettings_tenant_required.py
@@ -0,0 +1,15 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("netbox_utilities", "0001_initial"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="utilitiessettings",
+ name="tenant_required",
+ field=models.BooleanField(default=True),
+ ),
+ ]
diff --git a/netbox_utilities/models.py b/netbox_utilities/models.py
index 4bb29c4..13d5a6f 100644
--- a/netbox_utilities/models.py
+++ b/netbox_utilities/models.py
@@ -28,6 +28,7 @@ class UtilitiesSettings(models.Model):
validators=[MinValueValidator(1)],
)
tenant_filter_enabled = models.BooleanField(default=True)
+ tenant_required = models.BooleanField(default=True)
updated = models.DateTimeField(auto_now=True)
class Meta:
diff --git a/netbox_utilities/runtime.py b/netbox_utilities/runtime.py
index 676e70c..0631b79 100644
--- a/netbox_utilities/runtime.py
+++ b/netbox_utilities/runtime.py
@@ -4,7 +4,7 @@ from netbox.plugins import get_plugin_config
from .models import UtilitiesSettings
-SETTINGS_CACHE_KEY = "netbox_utilities.runtime_settings.v1"
+SETTINGS_CACHE_KEY = "netbox_utilities.runtime_settings.v2"
def navigation_customization_enabled():
@@ -15,18 +15,34 @@ 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:
+ return bool(_get_database_settings()["tenant_filter_enabled"])
+
+
+def tenant_required():
+ if not get_plugin_config("netbox_utilities", "tenant_required"):
+ return False
+
+ return bool(_get_database_settings()["tenant_required"])
+
+
+def _get_database_settings():
+ runtime_settings = cache.get(SETTINGS_CACHE_KEY)
+ if runtime_settings is None:
try:
- enabled = (
- UtilitiesSettings.objects.filter(singleton_id=1).values_list("tenant_filter_enabled", flat=True).first()
+ runtime_settings = (
+ UtilitiesSettings.objects.filter(singleton_id=1)
+ .values("tenant_filter_enabled", "tenant_required")
+ .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)
+ runtime_settings = None
+ runtime_settings = runtime_settings or {
+ "tenant_filter_enabled": True,
+ "tenant_required": True,
+ }
+ cache.set(SETTINGS_CACHE_KEY, runtime_settings, timeout=30)
+ return runtime_settings
def clear_runtime_settings_cache():
diff --git a/netbox_utilities/template_content.py b/netbox_utilities/template_content.py
index f3c34fe..d764097 100644
--- a/netbox_utilities/template_content.py
+++ b/netbox_utilities/template_content.py
@@ -1,5 +1,5 @@
from netbox.plugins import PluginTemplateExtension
-from tenancy.models import Tenant
+from tenancy.models import Tenant, TenantGroup
from .models import NavigationPreference
from .navigation_helpers import get_visible_menus, normalize_preferences
@@ -37,11 +37,14 @@ class UtilitiesGlobalContent(PluginTemplateExtension):
return ""
tenants = Tenant.objects.restrict(request.user, "view").order_by("name")
+ tenant_groups = TenantGroup.objects.restrict(request.user, "view").order_by("name")
return self.render(
"netbox_utilities/tenant_dropdown.html",
{
"tenants": tenants,
+ "tenant_groups": tenant_groups,
"selected_tenant": getattr(request, "netbox_utilities_tenant", None),
+ "selected_tenant_group": getattr(request, "netbox_utilities_tenant_group", None),
"return_url": request.get_full_path(),
},
)
diff --git a/netbox_utilities/templates/netbox_utilities/settings.html b/netbox_utilities/templates/netbox_utilities/settings.html
index e2c1160..f63daad 100644
--- a/netbox_utilities/templates/netbox_utilities/settings.html
+++ b/netbox_utilities/templates/netbox_utilities/settings.html
@@ -10,13 +10,18 @@
-
Mandantenfilter
+
Mandanten
- {% if config_forces_disabled %}
+ {% if tenant_filter_locked %}
Der Mandantenfilter ist in PLUGINS_CONFIG fest deaktiviert und kann hier nicht aktiviert werden.
{% endif %}
+ {% if tenant_required_locked %}
+
+ Die verpflichtende Mandantenzuordnung ist in PLUGINS_CONFIG fest deaktiviert.
+
+ {% endif %}
{{ form.tenant_filter_enabled.help_text }}
-
+
+ {{ form.tenant_required }}
+
+
+
{{ form.tenant_required.help_text }}
+
+ Die Pflicht gilt nur für Objekttypen, die in NetBox ein Mandantenfeld besitzen.
+ Bestehende Objekte ohne Mandant müssen beim nächsten Speichern ergänzt werden.
+