feat: add tenant groups and required tenancy

This commit is contained in:
2026-07-28 16:57:52 +02:00
parent 54f6a3f6c9
commit 35e06ee004
17 changed files with 427 additions and 75 deletions
+30 -9
View File
@@ -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.
+4 -1
View File
@@ -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
+12 -1
View File
@@ -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"}),
}
+50 -14
View File
@@ -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
@@ -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),
),
]
+1
View File
@@ -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:
+25 -9
View File
@@ -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():
+4 -1
View File
@@ -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(),
},
)
@@ -10,13 +10,18 @@
<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-header"><h2 class="card-title">Mandanten</h2></div>
<div class="card-body">
{% if config_forces_disabled %}
{% if tenant_filter_locked %}
<div class="alert alert-warning">
Der Mandantenfilter ist in <code>PLUGINS_CONFIG</code> fest deaktiviert und kann hier nicht aktiviert werden.
</div>
{% endif %}
{% if tenant_required_locked %}
<div class="alert alert-warning">
Die verpflichtende Mandantenzuordnung ist in <code>PLUGINS_CONFIG</code> fest deaktiviert.
</div>
{% endif %}
<form method="post">
{% csrf_token %}
{% if form.non_field_errors %}<div class="alert alert-danger">{{ form.non_field_errors }}</div>{% endif %}
@@ -27,9 +32,18 @@
</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>
<div class="form-check form-switch mt-4">
{{ form.tenant_required }}
<label class="form-check-label fw-bold" for="{{ form.tenant_required.id_for_label }}">
{{ form.tenant_required.label }}
</label>
</div>
<div class="form-text mb-3">{{ form.tenant_required.help_text }}</div>
<div class="alert alert-info">
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.
</div>
<button class="btn btn-primary" type="submit">Speichern</button>
</form>
</div>
</div>
@@ -9,7 +9,13 @@
>
<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 %}
{% if selected_tenant %}
{{ selected_tenant }}
{% elif selected_tenant_group %}
{{ selected_tenant_group }}
{% else %}
Alle Mandanten
{% endif %}
</span>
<i class="mdi mdi-chevron-down" aria-hidden="true"></i>
</button>
@@ -18,26 +24,39 @@
{% csrf_token %}
<input type="hidden" name="next" value="{{ return_url }}">
<label class="form-label fw-bold">
Mandant
Mandant oder Mandantengruppe
</label>
<select
class="form-select"
name="tenant"
name="scope"
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 %}
{% if tenant_groups %}
<optgroup label="Mandantengruppen">
{% for group in tenant_groups %}
<option value="group:{{ group.pk }}"{% if selected_tenant_group.pk == group.pk %} selected{% endif %}>
{{ group.name }}
</option>
{% endfor %}
</optgroup>
{% endif %}
{% if tenants %}
<optgroup label="Mandanten">
{% for tenant in tenants %}
<option value="tenant:{{ tenant.pk }}"{% if selected_tenant.pk == tenant.pk %} selected{% endif %}>
{{ tenant.name }}
</option>
{% endfor %}
</optgroup>
{% endif %}
</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.
Gruppen schließen alle direkt und untergeordnet zugeordneten Mandanten ein.
</p>
</form>
</div>
+21 -7
View File
@@ -1,9 +1,19 @@
from contextvars import ContextVar
from dataclasses import dataclass
from functools import wraps
from tenancy.models import Tenant
active_tenant_id = ContextVar("netbox_utilities_active_tenant_id", default=None)
@dataclass(frozen=True)
class ActiveTenantScope:
kind: str
object_id: int
tenant_ids: frozenset[int]
group_ids: frozenset[int] = frozenset()
active_tenant_scope = ContextVar("netbox_utilities_active_tenant_scope", default=None)
_PARENT_RELATIONS = (
"device",
@@ -22,6 +32,10 @@ _PARENT_RELATIONS = (
def object_matches_tenant(obj, tenant_id, depth=0):
return object_matches_tenant_ids(obj, frozenset({tenant_id}), depth=depth)
def object_matches_tenant_ids(obj, tenant_ids, depth=0):
"""
Return whether a search result belongs to the selected tenant.
@@ -31,11 +45,11 @@ def object_matches_tenant(obj, tenant_id, depth=0):
if obj is None:
return False
if isinstance(obj, Tenant):
return obj.pk == tenant_id
return obj.pk in tenant_ids
field_names = {field.name for field in obj._meta.get_fields()}
if "tenant" in field_names:
return getattr(obj, "tenant_id", None) == tenant_id
return getattr(obj, "tenant_id", None) in tenant_ids
if depth < 2:
for relation in _PARENT_RELATIONS:
@@ -44,7 +58,7 @@ def object_matches_tenant(obj, tenant_id, depth=0):
parent = getattr(obj, relation, None)
if parent is None:
return False
return object_matches_tenant(parent, tenant_id, depth + 1)
return object_matches_tenant_ids(parent, tenant_ids, depth + 1)
# Global reference objects (roles, manufacturers, statuses, etc.) are not
# tenant-owned and must remain usable while a filter is active.
@@ -63,10 +77,10 @@ def install_search_filter():
@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:
scope = active_tenant_scope.get()
if scope is None:
return results
return [result for result in results if object_matches_tenant(result.object, tenant_id)]
return [result for result in results if object_matches_tenant_ids(result.object, scope.tenant_ids)]
search_backend.search = tenant_aware_search
search_backend._netbox_utilities_wrapped = True
+76
View File
@@ -0,0 +1,76 @@
from functools import wraps
from django.apps import apps
from django.core.exceptions import FieldDoesNotExist, ValidationError
from django.db.models.signals import pre_save
from django.forms.models import BaseModelForm
from tenancy.models import Tenant
from .runtime import tenant_required
VALIDATION_MESSAGE = "Für dieses Objekt muss ein Mandant angegeben werden."
def model_supports_tenant(model):
try:
field = model._meta.get_field("tenant")
except FieldDoesNotExist:
return False
return field.concrete and field.is_relation and field.related_model is Tenant
def validate_tenant_assignment(instance):
if tenant_required() and model_supports_tenant(type(instance)) and getattr(instance, "tenant_id", None) is None:
# A non-field error is intentional: a small number of specialized
# ModelForms omit the tenant field and Django cannot attach errors to
# fields which are absent from a form.
raise ValidationError(VALIDATION_MESSAGE)
def _install_model_clean_validation(model):
if not model_supports_tenant(model) or getattr(model, "_netbox_utilities_tenant_validation", False):
return
original_clean = model.clean
@wraps(original_clean)
def tenant_aware_clean(instance, *args, **kwargs):
result = original_clean(instance, *args, **kwargs)
validate_tenant_assignment(instance)
return result
model.clean = tenant_aware_clean
model._netbox_utilities_tenant_validation = True
def _install_form_validation():
if getattr(BaseModelForm, "_netbox_utilities_tenant_validation", False):
return
original_init = BaseModelForm.__init__
@wraps(original_init)
def tenant_aware_init(form, *args, **kwargs):
original_init(form, *args, **kwargs)
model = getattr(form._meta, "model", None)
if tenant_required() and model and model_supports_tenant(model) and "tenant" in form.fields:
form.fields["tenant"].required = True
BaseModelForm.__init__ = tenant_aware_init
BaseModelForm._netbox_utilities_tenant_validation = True
def _enforce_before_save(sender, instance, raw=False, **kwargs):
if not raw:
validate_tenant_assignment(instance)
def install_tenant_validation():
for model in apps.get_models():
_install_model_clean_validation(model)
_install_form_validation()
pre_save.connect(
_enforce_before_save,
dispatch_uid="netbox_utilities.enforce_tenant_before_save",
weak=False,
)
+40 -3
View File
@@ -4,6 +4,7 @@ from unittest.mock import patch
from django.test import RequestFactory, SimpleTestCase
from netbox_utilities.middleware import GlobalTenantFilterMiddleware
from netbox_utilities.tenant_scope import ActiveTenantScope
class TenantFilterSet:
@@ -32,7 +33,8 @@ class GlobalTenantFilterMiddlewareTest(SimpleTestCase):
mocked_resolve.return_value = resolver_match(TenantFilterSet)
request = self.factory.get("/dcim/devices/?tenant_id=7&status=active")
GlobalTenantFilterMiddleware._inject_filter_parameter(request, 42)
scope = ActiveTenantScope("tenant", 42, frozenset({42}))
GlobalTenantFilterMiddleware._inject_filter_parameter(request, scope)
self.assertEqual(request.GET.getlist("tenant_id"), ["42"])
self.assertEqual(request.GET["status"], "active")
@@ -42,7 +44,8 @@ class GlobalTenantFilterMiddlewareTest(SimpleTestCase):
mocked_resolve.return_value = resolver_match(SharedFilterSet)
request = self.factory.get("/dcim/manufacturers/?status=active")
GlobalTenantFilterMiddleware._inject_filter_parameter(request, 42)
scope = ActiveTenantScope("tenant", 42, frozenset({42}))
GlobalTenantFilterMiddleware._inject_filter_parameter(request, scope)
self.assertNotIn("tenant_id", request.GET)
self.assertEqual(request.GET["status"], "active")
@@ -52,6 +55,40 @@ class GlobalTenantFilterMiddlewareTest(SimpleTestCase):
mocked_resolve.return_value = resolver_match(None, "tenancy:tenant_list")
request = self.factory.get("/tenancy/tenants/")
GlobalTenantFilterMiddleware._inject_filter_parameter(request, 42)
scope = ActiveTenantScope("tenant", 42, frozenset({42}))
GlobalTenantFilterMiddleware._inject_filter_parameter(request, scope)
self.assertEqual(request.GET.getlist("id"), ["42"])
@patch("netbox_utilities.middleware.resolve")
def test_injects_tenant_group(self, mocked_resolve):
class TenantGroupFilterSet:
base_filters = {"tenant_id": object(), "tenant_group_id": object()}
mocked_resolve.return_value = resolver_match(TenantGroupFilterSet)
request = self.factory.get("/dcim/devices/")
scope = ActiveTenantScope("group", 8, frozenset({42, 43}))
GlobalTenantFilterMiddleware._inject_filter_parameter(request, scope)
self.assertEqual(request.GET.getlist("tenant_group_id"), ["8"])
@patch("netbox_utilities.middleware.resolve")
def test_group_falls_back_to_tenant_ids(self, mocked_resolve):
mocked_resolve.return_value = resolver_match(TenantFilterSet)
request = self.factory.get("/dcim/virtual-chassis/")
scope = ActiveTenantScope("group", 8, frozenset({42, 43}))
GlobalTenantFilterMiddleware._inject_filter_parameter(request, scope)
self.assertCountEqual(request.GET.getlist("tenant_id"), ["42", "43"])
@patch("netbox_utilities.middleware.resolve")
def test_tenant_group_list_includes_descendant_groups(self, mocked_resolve):
mocked_resolve.return_value = resolver_match(None, "tenancy:tenantgroup_list")
request = self.factory.get("/tenancy/tenant-groups/")
scope = ActiveTenantScope("group", 8, frozenset({42}), frozenset({8, 9}))
GlobalTenantFilterMiddleware._inject_filter_parameter(request, scope)
self.assertCountEqual(request.GET.getlist("id"), ["8", "9"])
+6 -1
View File
@@ -2,7 +2,7 @@ from types import SimpleNamespace
from django.test import SimpleTestCase
from netbox_utilities.tenant_scope import object_matches_tenant
from netbox_utilities.tenant_scope import object_matches_tenant, object_matches_tenant_ids
class FakeMeta:
@@ -32,3 +32,8 @@ class TenantScopeTest(SimpleTestCase):
def test_unassigned_tenant_object_is_hidden(self):
obj = SimpleNamespace(_meta=FakeMeta("tenant"), tenant_id=None)
self.assertFalse(object_matches_tenant(obj, 42))
def test_object_matches_any_tenant_in_group_scope(self):
obj = SimpleNamespace(_meta=FakeMeta("tenant"), tenant_id=43)
self.assertTrue(object_matches_tenant_ids(obj, frozenset({42, 43})))
self.assertFalse(object_matches_tenant_ids(obj, frozenset({7, 8})))
@@ -0,0 +1,50 @@
from types import SimpleNamespace
from unittest.mock import patch
from django.core.exceptions import FieldDoesNotExist, ValidationError
from django.test import SimpleTestCase
from tenancy.models import Tenant
from netbox_utilities.tenant_validation import model_supports_tenant, validate_tenant_assignment
class FakeMeta:
def __init__(self, supports_tenant=True):
self.supports_tenant = supports_tenant
def get_field(self, name):
if name != "tenant" or not self.supports_tenant:
raise FieldDoesNotExist(name)
return SimpleNamespace(concrete=True, is_relation=True, related_model=Tenant)
class TenantModel:
_meta = FakeMeta()
def __init__(self, tenant_id=None):
self.tenant_id = tenant_id
class SharedModel:
_meta = FakeMeta(supports_tenant=False)
class TenantValidationTest(SimpleTestCase):
def test_detects_tenant_capable_model(self):
self.assertTrue(model_supports_tenant(TenantModel))
self.assertFalse(model_supports_tenant(SharedModel))
@patch("netbox_utilities.tenant_validation.tenant_required", return_value=True)
def test_rejects_missing_tenant_when_required(self, _tenant_required):
with self.assertRaises(ValidationError) as context:
validate_tenant_assignment(TenantModel())
self.assertEqual(context.exception.messages, ["Für dieses Objekt muss ein Mandant angegeben werden."])
@patch("netbox_utilities.tenant_validation.tenant_required", return_value=True)
def test_accepts_assigned_tenant(self, _tenant_required):
validate_tenant_assignment(TenantModel(tenant_id=42))
@patch("netbox_utilities.tenant_validation.tenant_required", return_value=False)
def test_allows_missing_tenant_when_disabled(self, _tenant_required):
validate_tenant_assignment(TenantModel())
+45 -14
View File
@@ -6,10 +6,14 @@ 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 tenancy.models import Tenant, TenantGroup
from .forms import UtilitiesSettingsForm
from .middleware import SESSION_TENANT_KEY
from .middleware import (
SESSION_TENANT_GROUP_KEY,
SESSION_TENANT_KEY,
GlobalTenantFilterMiddleware,
)
from .models import NavigationPreference, UtilitiesSettings
from .navigation_helpers import get_visible_menus, normalize_preferences
from .runtime import (
@@ -87,25 +91,35 @@ class UtilitiesSettingsView(LoginRequiredMixin, UserPassesTestMixin, View):
def get(self, request):
instance, _ = UtilitiesSettings.objects.get_or_create(singleton_id=1)
tenant_filter_locked = not get_plugin_config("netbox_utilities", "tenant_filter_enabled")
tenant_required_locked = not get_plugin_config("netbox_utilities", "tenant_required")
return render(
request,
self.template_name,
{
"form": UtilitiesSettingsForm(instance=instance),
"config_forces_disabled": not get_plugin_config("netbox_utilities", "tenant_filter_enabled"),
"form": UtilitiesSettingsForm(
instance=instance,
tenant_filter_locked=tenant_filter_locked,
tenant_required_locked=tenant_required_locked,
),
"tenant_filter_locked": tenant_filter_locked,
"tenant_required_locked": tenant_required_locked,
},
)
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)
form = UtilitiesSettingsForm(
request.POST,
instance=instance,
tenant_filter_locked=not get_plugin_config("netbox_utilities", "tenant_filter_enabled"),
tenant_required_locked=not get_plugin_config("netbox_utilities", "tenant_required"),
)
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)
if not tenant_filter_enabled():
GlobalTenantFilterMiddleware.clear_scope(request)
messages.success(request, "Die Plugin-Einstellungen wurden gespeichert.")
return redirect("plugins:netbox_utilities:settings")
return render(request, self.template_name, {"form": form})
@@ -118,15 +132,32 @@ class SelectTenantView(LoginRequiredMixin, View):
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()
scope_value = request.POST.get("scope") or request.POST.get("tenant", "")
if not scope_value:
GlobalTenantFilterMiddleware.clear_scope(request)
messages.success(request, "Der globale Mandantenfilter wurde aufgehoben.")
return redirect(_safe_return_url(request))
if ":" in scope_value:
scope_kind, object_id = scope_value.split(":", 1)
else:
scope_kind, object_id = "tenant", scope_value
if scope_kind == "tenant":
tenant = Tenant.objects.restrict(request.user, "view").filter(pk=object_id).first()
if tenant is None:
return HttpResponseBadRequest("Unbekannter oder nicht erlaubter Mandant.")
request.session.pop(SESSION_TENANT_GROUP_KEY, None)
request.session[SESSION_TENANT_KEY] = tenant.pk
messages.success(request, f"Globaler Mandantenfilter: {tenant}")
else:
elif scope_kind == "group":
group = TenantGroup.objects.restrict(request.user, "view").filter(pk=object_id).first()
if group is None:
return HttpResponseBadRequest("Unbekannte oder nicht erlaubte Mandantengruppe.")
request.session.pop(SESSION_TENANT_KEY, None)
messages.success(request, "Der globale Mandantenfilter wurde aufgehoben.")
request.session[SESSION_TENANT_GROUP_KEY] = group.pk
messages.success(request, f"Globale Mandantengruppe: {group}")
else:
return HttpResponseBadRequest("Ungültiger Mandantenfilter.")
return redirect(_safe_return_url(request))
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "netbox-utilities"
version = "0.1.0"
version = "0.2.0"
description = "Personal navigation and a global tenant filter for NetBox 4.6"
readme = "README.md"
requires-python = ">=3.12"