feat: add tenant groups and required tenancy
This commit is contained in:
@@ -1,11 +1,12 @@
|
|||||||
# NetBox Utilities
|
# 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.
|
- 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
|
## Kompatibilität
|
||||||
|
|
||||||
@@ -28,7 +29,7 @@ Release-Tag oder ein bestimmter Commit verwendet werden:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
/opt/netbox/venv/bin/pip install --upgrade --force-reinstall \
|
/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.
|
Alternativ kann hinter dem `@` die vollständige Commit-ID stehen.
|
||||||
@@ -61,6 +62,7 @@ PLUGINS_CONFIG = {
|
|||||||
"netbox_utilities": {
|
"netbox_utilities": {
|
||||||
"navigation_customization_enabled": True,
|
"navigation_customization_enabled": True,
|
||||||
"tenant_filter_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.
|
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:
|
Der Filter:
|
||||||
|
|
||||||
- erzwingt `tenant_id` in allen NetBox-Listen, deren FilterSet dieses Feld unterstützt;
|
- erzwingt je nach Auswahl `tenant_id` oder `tenant_group_id` in unterstützten NetBox-Listen;
|
||||||
- beschränkt die Mandantenliste auf den gewählten Mandanten;
|
- beschränkt die Mandantenliste auf den gewählten Mandanten beziehungsweise die gewählte Gruppe;
|
||||||
- beschränkt Ergebnisse der globalen NetBox-Suche;
|
- beschränkt Ergebnisse der globalen NetBox-Suche;
|
||||||
- gilt auch für HTMX-Tabellenupdates und Exporte aus einer gefilterten Liste;
|
- gilt auch für HTMX-Tabellenupdates und Exporte aus einer gefilterten Liste;
|
||||||
- verändert keine REST- oder GraphQL-Anfrage.
|
- 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.
|
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
|
## 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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ class NetBoxUtilitiesConfig(PluginConfig):
|
|||||||
name = "netbox_utilities"
|
name = "netbox_utilities"
|
||||||
verbose_name = "NetBox Utilities"
|
verbose_name = "NetBox Utilities"
|
||||||
description = "Personal navigation and a global tenant filter"
|
description = "Personal navigation and a global tenant filter"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
author = "LKE"
|
author = "LKE"
|
||||||
base_url = "utilities"
|
base_url = "utilities"
|
||||||
min_version = "4.6.5"
|
min_version = "4.6.5"
|
||||||
@@ -13,6 +13,7 @@ class NetBoxUtilitiesConfig(PluginConfig):
|
|||||||
default_settings = {
|
default_settings = {
|
||||||
"navigation_customization_enabled": True,
|
"navigation_customization_enabled": True,
|
||||||
"tenant_filter_enabled": True,
|
"tenant_filter_enabled": True,
|
||||||
|
"tenant_required": True,
|
||||||
}
|
}
|
||||||
middleware = [
|
middleware = [
|
||||||
"netbox_utilities.middleware.GlobalTenantFilterMiddleware",
|
"netbox_utilities.middleware.GlobalTenantFilterMiddleware",
|
||||||
@@ -21,8 +22,10 @@ class NetBoxUtilitiesConfig(PluginConfig):
|
|||||||
def ready(self):
|
def ready(self):
|
||||||
super().ready()
|
super().ready()
|
||||||
from .tenant_scope import install_search_filter
|
from .tenant_scope import install_search_filter
|
||||||
|
from .tenant_validation import install_tenant_validation
|
||||||
|
|
||||||
install_search_filter()
|
install_search_filter()
|
||||||
|
install_tenant_validation()
|
||||||
|
|
||||||
|
|
||||||
config = NetBoxUtilitiesConfig
|
config = NetBoxUtilitiesConfig
|
||||||
|
|||||||
@@ -4,18 +4,29 @@ from .models import UtilitiesSettings
|
|||||||
|
|
||||||
|
|
||||||
class UtilitiesSettingsForm(forms.ModelForm):
|
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:
|
class Meta:
|
||||||
model = UtilitiesSettings
|
model = UtilitiesSettings
|
||||||
fields = ("tenant_filter_enabled",)
|
fields = ("tenant_filter_enabled", "tenant_required")
|
||||||
labels = {
|
labels = {
|
||||||
"tenant_filter_enabled": "Globalen Mandantenfilter aktivieren",
|
"tenant_filter_enabled": "Globalen Mandantenfilter aktivieren",
|
||||||
|
"tenant_required": "Mandant für mandantenfähige Objekte verpflichtend machen",
|
||||||
}
|
}
|
||||||
help_texts = {
|
help_texts = {
|
||||||
"tenant_filter_enabled": (
|
"tenant_filter_enabled": (
|
||||||
"Blendet das Mandanten-Dropdown ein und ergänzt unterstützte NetBox-Listen "
|
"Blendet das Mandanten-Dropdown ein und ergänzt unterstützte NetBox-Listen "
|
||||||
"automatisch um den gewählten Mandantenfilter."
|
"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 = {
|
widgets = {
|
||||||
"tenant_filter_enabled": forms.CheckboxInput(attrs={"class": "form-check-input"}),
|
"tenant_filter_enabled": forms.CheckboxInput(attrs={"class": "form-check-input"}),
|
||||||
|
"tenant_required": forms.CheckboxInput(attrs={"class": "form-check-input"}),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
from django.urls import Resolver404, resolve
|
from django.urls import Resolver404, resolve
|
||||||
from tenancy.models import Tenant
|
from tenancy.models import Tenant, TenantGroup
|
||||||
|
|
||||||
from .runtime import tenant_filter_enabled
|
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_KEY = "netbox_utilities.selected_tenant_id"
|
||||||
|
SESSION_TENANT_GROUP_KEY = "netbox_utilities.selected_tenant_group_id"
|
||||||
|
|
||||||
|
|
||||||
class GlobalTenantFilterMiddleware:
|
class GlobalTenantFilterMiddleware:
|
||||||
@@ -15,35 +16,53 @@ class GlobalTenantFilterMiddleware:
|
|||||||
|
|
||||||
def __call__(self, request):
|
def __call__(self, request):
|
||||||
request.netbox_utilities_tenant = None
|
request.netbox_utilities_tenant = None
|
||||||
tenant_id = self._get_selected_tenant_id(request)
|
request.netbox_utilities_tenant_group = None
|
||||||
token = active_tenant_id.set(tenant_id)
|
scope = self._get_selected_scope(request)
|
||||||
|
token = active_tenant_scope.set(scope)
|
||||||
try:
|
try:
|
||||||
if tenant_id is not None and request.method in {"GET", "HEAD"}:
|
if scope is not None and request.method in {"GET", "HEAD"}:
|
||||||
self._inject_filter_parameter(request, tenant_id)
|
self._inject_filter_parameter(request, scope)
|
||||||
return self.get_response(request)
|
return self.get_response(request)
|
||||||
finally:
|
finally:
|
||||||
active_tenant_id.reset(token)
|
active_tenant_scope.reset(token)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _get_selected_tenant_id(request):
|
def _get_selected_scope(request):
|
||||||
if not tenant_filter_enabled() or not request.user.is_authenticated:
|
if not tenant_filter_enabled() or not request.user.is_authenticated:
|
||||||
request.session.pop(SESSION_TENANT_KEY, None)
|
GlobalTenantFilterMiddleware.clear_scope(request)
|
||||||
return None
|
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)
|
tenant_id = request.session.get(SESSION_TENANT_KEY)
|
||||||
if tenant_id is None:
|
if tenant_id is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
tenant = Tenant.objects.restrict(request.user, "view").filter(pk=tenant_id).first()
|
tenant = Tenant.objects.restrict(request.user, "view").filter(pk=tenant_id).first()
|
||||||
if tenant is None:
|
if tenant is None:
|
||||||
request.session.pop(SESSION_TENANT_KEY, None)
|
GlobalTenantFilterMiddleware.clear_scope(request)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
request.netbox_utilities_tenant = tenant
|
request.netbox_utilities_tenant = tenant
|
||||||
return tenant.pk
|
return ActiveTenantScope("tenant", tenant.pk, frozenset({tenant.pk}))
|
||||||
|
|
||||||
@staticmethod
|
@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:
|
try:
|
||||||
match = resolve(request.path_info)
|
match = resolve(request.path_info)
|
||||||
except Resolver404:
|
except Resolver404:
|
||||||
@@ -54,15 +73,32 @@ class GlobalTenantFilterMiddleware:
|
|||||||
return
|
return
|
||||||
|
|
||||||
parameter = None
|
parameter = None
|
||||||
|
values = []
|
||||||
if match.view_name == "tenancy:tenant_list":
|
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"
|
parameter = "id"
|
||||||
|
values = list(scope.group_ids) or [scope.object_id]
|
||||||
else:
|
else:
|
||||||
view_class = getattr(match.func, "view_class", None)
|
view_class = getattr(match.func, "view_class", None)
|
||||||
filterset = getattr(view_class, "filterset", 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"
|
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:
|
if parameter:
|
||||||
query = request.GET.copy()
|
query = request.GET.copy()
|
||||||
query.setlist(parameter, [str(tenant_id)])
|
query.setlist(parameter, [str(value) for value in values])
|
||||||
request.GET = query
|
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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -28,6 +28,7 @@ class UtilitiesSettings(models.Model):
|
|||||||
validators=[MinValueValidator(1)],
|
validators=[MinValueValidator(1)],
|
||||||
)
|
)
|
||||||
tenant_filter_enabled = models.BooleanField(default=True)
|
tenant_filter_enabled = models.BooleanField(default=True)
|
||||||
|
tenant_required = models.BooleanField(default=True)
|
||||||
updated = models.DateTimeField(auto_now=True)
|
updated = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from netbox.plugins import get_plugin_config
|
|||||||
|
|
||||||
from .models import UtilitiesSettings
|
from .models import UtilitiesSettings
|
||||||
|
|
||||||
SETTINGS_CACHE_KEY = "netbox_utilities.runtime_settings.v1"
|
SETTINGS_CACHE_KEY = "netbox_utilities.runtime_settings.v2"
|
||||||
|
|
||||||
|
|
||||||
def navigation_customization_enabled():
|
def navigation_customization_enabled():
|
||||||
@@ -15,18 +15,34 @@ def tenant_filter_enabled():
|
|||||||
if not get_plugin_config("netbox_utilities", "tenant_filter_enabled"):
|
if not get_plugin_config("netbox_utilities", "tenant_filter_enabled"):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
enabled = cache.get(SETTINGS_CACHE_KEY)
|
return bool(_get_database_settings()["tenant_filter_enabled"])
|
||||||
if enabled is None:
|
|
||||||
|
|
||||||
|
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:
|
try:
|
||||||
enabled = (
|
runtime_settings = (
|
||||||
UtilitiesSettings.objects.filter(singleton_id=1).values_list("tenant_filter_enabled", flat=True).first()
|
UtilitiesSettings.objects.filter(singleton_id=1)
|
||||||
|
.values("tenant_filter_enabled", "tenant_required")
|
||||||
|
.first()
|
||||||
)
|
)
|
||||||
except (OperationalError, ProgrammingError):
|
except (OperationalError, ProgrammingError):
|
||||||
# The middleware can be imported before the first plugin migration.
|
# The middleware can be imported before the first plugin migration.
|
||||||
enabled = True
|
runtime_settings = None
|
||||||
enabled = True if enabled is None else enabled
|
runtime_settings = runtime_settings or {
|
||||||
cache.set(SETTINGS_CACHE_KEY, enabled, timeout=30)
|
"tenant_filter_enabled": True,
|
||||||
return bool(enabled)
|
"tenant_required": True,
|
||||||
|
}
|
||||||
|
cache.set(SETTINGS_CACHE_KEY, runtime_settings, timeout=30)
|
||||||
|
return runtime_settings
|
||||||
|
|
||||||
|
|
||||||
def clear_runtime_settings_cache():
|
def clear_runtime_settings_cache():
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from netbox.plugins import PluginTemplateExtension
|
from netbox.plugins import PluginTemplateExtension
|
||||||
from tenancy.models import Tenant
|
from tenancy.models import Tenant, TenantGroup
|
||||||
|
|
||||||
from .models import NavigationPreference
|
from .models import NavigationPreference
|
||||||
from .navigation_helpers import get_visible_menus, normalize_preferences
|
from .navigation_helpers import get_visible_menus, normalize_preferences
|
||||||
@@ -37,11 +37,14 @@ class UtilitiesGlobalContent(PluginTemplateExtension):
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
tenants = Tenant.objects.restrict(request.user, "view").order_by("name")
|
tenants = Tenant.objects.restrict(request.user, "view").order_by("name")
|
||||||
|
tenant_groups = TenantGroup.objects.restrict(request.user, "view").order_by("name")
|
||||||
return self.render(
|
return self.render(
|
||||||
"netbox_utilities/tenant_dropdown.html",
|
"netbox_utilities/tenant_dropdown.html",
|
||||||
{
|
{
|
||||||
"tenants": tenants,
|
"tenants": tenants,
|
||||||
|
"tenant_groups": tenant_groups,
|
||||||
"selected_tenant": getattr(request, "netbox_utilities_tenant", None),
|
"selected_tenant": getattr(request, "netbox_utilities_tenant", None),
|
||||||
|
"selected_tenant_group": getattr(request, "netbox_utilities_tenant_group", None),
|
||||||
"return_url": request.get_full_path(),
|
"return_url": request.get_full_path(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,13 +10,18 @@
|
|||||||
<div class="row justify-content-center">
|
<div class="row justify-content-center">
|
||||||
<div class="col col-md-9 col-xl-7">
|
<div class="col col-md-9 col-xl-7">
|
||||||
<div class="card">
|
<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">
|
<div class="card-body">
|
||||||
{% if config_forces_disabled %}
|
{% if tenant_filter_locked %}
|
||||||
<div class="alert alert-warning">
|
<div class="alert alert-warning">
|
||||||
Der Mandantenfilter ist in <code>PLUGINS_CONFIG</code> fest deaktiviert und kann hier nicht aktiviert werden.
|
Der Mandantenfilter ist in <code>PLUGINS_CONFIG</code> fest deaktiviert und kann hier nicht aktiviert werden.
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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">
|
<form method="post">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{% if form.non_field_errors %}<div class="alert alert-danger">{{ form.non_field_errors }}</div>{% endif %}
|
{% if form.non_field_errors %}<div class="alert alert-danger">{{ form.non_field_errors }}</div>{% endif %}
|
||||||
@@ -27,9 +32,18 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-text mb-3">{{ form.tenant_filter_enabled.help_text }}</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 %}>
|
<div class="form-check form-switch mt-4">
|
||||||
Speichern
|
{{ form.tenant_required }}
|
||||||
</button>
|
<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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,7 +9,13 @@
|
|||||||
>
|
>
|
||||||
<i class="mdi mdi-domain" aria-hidden="true"></i>
|
<i class="mdi mdi-domain" aria-hidden="true"></i>
|
||||||
<span class="d-none d-xl-inline ms-1 tenant-filter-label">
|
<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>
|
</span>
|
||||||
<i class="mdi mdi-chevron-down" aria-hidden="true"></i>
|
<i class="mdi mdi-chevron-down" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
@@ -18,26 +24,39 @@
|
|||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<input type="hidden" name="next" value="{{ return_url }}">
|
<input type="hidden" name="next" value="{{ return_url }}">
|
||||||
<label class="form-label fw-bold">
|
<label class="form-label fw-bold">
|
||||||
Mandant
|
Mandant oder Mandantengruppe
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
class="form-select"
|
class="form-select"
|
||||||
name="tenant"
|
name="scope"
|
||||||
onchange="this.form.submit()"
|
onchange="this.form.submit()"
|
||||||
aria-label="Mandant global auswählen"
|
aria-label="Mandant global auswählen"
|
||||||
>
|
>
|
||||||
<option value="">Alle Mandanten</option>
|
<option value="">Alle Mandanten</option>
|
||||||
{% for tenant in tenants %}
|
{% if tenant_groups %}
|
||||||
<option value="{{ tenant.pk }}"{% if selected_tenant.pk == tenant.pk %} selected{% endif %}>
|
<optgroup label="Mandantengruppen">
|
||||||
{{ tenant.name }}
|
{% for group in tenant_groups %}
|
||||||
</option>
|
<option value="group:{{ group.pk }}"{% if selected_tenant_group.pk == group.pk %} selected{% endif %}>
|
||||||
{% endfor %}
|
{{ 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>
|
</select>
|
||||||
<noscript>
|
<noscript>
|
||||||
<button class="btn btn-primary w-100 mt-2" type="submit">Anwenden</button>
|
<button class="btn btn-primary w-100 mt-2" type="submit">Anwenden</button>
|
||||||
</noscript>
|
</noscript>
|
||||||
<p class="text-secondary small mb-0 mt-2">
|
<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>
|
</p>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,19 @@
|
|||||||
from contextvars import ContextVar
|
from contextvars import ContextVar
|
||||||
|
from dataclasses import dataclass
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
|
||||||
from tenancy.models import Tenant
|
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 = (
|
_PARENT_RELATIONS = (
|
||||||
"device",
|
"device",
|
||||||
@@ -22,6 +32,10 @@ _PARENT_RELATIONS = (
|
|||||||
|
|
||||||
|
|
||||||
def object_matches_tenant(obj, tenant_id, depth=0):
|
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.
|
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:
|
if obj is None:
|
||||||
return False
|
return False
|
||||||
if isinstance(obj, Tenant):
|
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()}
|
field_names = {field.name for field in obj._meta.get_fields()}
|
||||||
if "tenant" in field_names:
|
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:
|
if depth < 2:
|
||||||
for relation in _PARENT_RELATIONS:
|
for relation in _PARENT_RELATIONS:
|
||||||
@@ -44,7 +58,7 @@ def object_matches_tenant(obj, tenant_id, depth=0):
|
|||||||
parent = getattr(obj, relation, None)
|
parent = getattr(obj, relation, None)
|
||||||
if parent is None:
|
if parent is None:
|
||||||
return False
|
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
|
# Global reference objects (roles, manufacturers, statuses, etc.) are not
|
||||||
# tenant-owned and must remain usable while a filter is active.
|
# tenant-owned and must remain usable while a filter is active.
|
||||||
@@ -63,10 +77,10 @@ def install_search_filter():
|
|||||||
@wraps(original_search)
|
@wraps(original_search)
|
||||||
def tenant_aware_search(*args, **kwargs):
|
def tenant_aware_search(*args, **kwargs):
|
||||||
results = original_search(*args, **kwargs)
|
results = original_search(*args, **kwargs)
|
||||||
tenant_id = active_tenant_id.get()
|
scope = active_tenant_scope.get()
|
||||||
if tenant_id is None:
|
if scope is None:
|
||||||
return results
|
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.search = tenant_aware_search
|
||||||
search_backend._netbox_utilities_wrapped = True
|
search_backend._netbox_utilities_wrapped = True
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -4,6 +4,7 @@ from unittest.mock import patch
|
|||||||
from django.test import RequestFactory, SimpleTestCase
|
from django.test import RequestFactory, SimpleTestCase
|
||||||
|
|
||||||
from netbox_utilities.middleware import GlobalTenantFilterMiddleware
|
from netbox_utilities.middleware import GlobalTenantFilterMiddleware
|
||||||
|
from netbox_utilities.tenant_scope import ActiveTenantScope
|
||||||
|
|
||||||
|
|
||||||
class TenantFilterSet:
|
class TenantFilterSet:
|
||||||
@@ -32,7 +33,8 @@ class GlobalTenantFilterMiddlewareTest(SimpleTestCase):
|
|||||||
mocked_resolve.return_value = resolver_match(TenantFilterSet)
|
mocked_resolve.return_value = resolver_match(TenantFilterSet)
|
||||||
request = self.factory.get("/dcim/devices/?tenant_id=7&status=active")
|
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.getlist("tenant_id"), ["42"])
|
||||||
self.assertEqual(request.GET["status"], "active")
|
self.assertEqual(request.GET["status"], "active")
|
||||||
@@ -42,7 +44,8 @@ class GlobalTenantFilterMiddlewareTest(SimpleTestCase):
|
|||||||
mocked_resolve.return_value = resolver_match(SharedFilterSet)
|
mocked_resolve.return_value = resolver_match(SharedFilterSet)
|
||||||
request = self.factory.get("/dcim/manufacturers/?status=active")
|
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.assertNotIn("tenant_id", request.GET)
|
||||||
self.assertEqual(request.GET["status"], "active")
|
self.assertEqual(request.GET["status"], "active")
|
||||||
@@ -52,6 +55,40 @@ class GlobalTenantFilterMiddlewareTest(SimpleTestCase):
|
|||||||
mocked_resolve.return_value = resolver_match(None, "tenancy:tenant_list")
|
mocked_resolve.return_value = resolver_match(None, "tenancy:tenant_list")
|
||||||
request = self.factory.get("/tenancy/tenants/")
|
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"])
|
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"])
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from types import SimpleNamespace
|
|||||||
|
|
||||||
from django.test import SimpleTestCase
|
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:
|
class FakeMeta:
|
||||||
@@ -32,3 +32,8 @@ class TenantScopeTest(SimpleTestCase):
|
|||||||
def test_unassigned_tenant_object_is_hidden(self):
|
def test_unassigned_tenant_object_is_hidden(self):
|
||||||
obj = SimpleNamespace(_meta=FakeMeta("tenant"), tenant_id=None)
|
obj = SimpleNamespace(_meta=FakeMeta("tenant"), tenant_id=None)
|
||||||
self.assertFalse(object_matches_tenant(obj, 42))
|
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
@@ -6,10 +6,14 @@ from django.urls import reverse
|
|||||||
from django.utils.http import url_has_allowed_host_and_scheme
|
from django.utils.http import url_has_allowed_host_and_scheme
|
||||||
from django.views import View
|
from django.views import View
|
||||||
from netbox.plugins import get_plugin_config
|
from netbox.plugins import get_plugin_config
|
||||||
from tenancy.models import Tenant
|
from tenancy.models import Tenant, TenantGroup
|
||||||
|
|
||||||
from .forms import UtilitiesSettingsForm
|
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 .models import NavigationPreference, UtilitiesSettings
|
||||||
from .navigation_helpers import get_visible_menus, normalize_preferences
|
from .navigation_helpers import get_visible_menus, normalize_preferences
|
||||||
from .runtime import (
|
from .runtime import (
|
||||||
@@ -87,25 +91,35 @@ class UtilitiesSettingsView(LoginRequiredMixin, UserPassesTestMixin, View):
|
|||||||
|
|
||||||
def get(self, request):
|
def get(self, request):
|
||||||
instance, _ = UtilitiesSettings.objects.get_or_create(singleton_id=1)
|
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(
|
return render(
|
||||||
request,
|
request,
|
||||||
self.template_name,
|
self.template_name,
|
||||||
{
|
{
|
||||||
"form": UtilitiesSettingsForm(instance=instance),
|
"form": UtilitiesSettingsForm(
|
||||||
"config_forces_disabled": not get_plugin_config("netbox_utilities", "tenant_filter_enabled"),
|
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):
|
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)
|
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():
|
if form.is_valid():
|
||||||
form.save()
|
form.save()
|
||||||
clear_runtime_settings_cache()
|
clear_runtime_settings_cache()
|
||||||
if not form.cleaned_data["tenant_filter_enabled"]:
|
if not tenant_filter_enabled():
|
||||||
request.session.pop(SESSION_TENANT_KEY, None)
|
GlobalTenantFilterMiddleware.clear_scope(request)
|
||||||
messages.success(request, "Die Plugin-Einstellungen wurden gespeichert.")
|
messages.success(request, "Die Plugin-Einstellungen wurden gespeichert.")
|
||||||
return redirect("plugins:netbox_utilities:settings")
|
return redirect("plugins:netbox_utilities:settings")
|
||||||
return render(request, self.template_name, {"form": form})
|
return render(request, self.template_name, {"form": form})
|
||||||
@@ -118,15 +132,32 @@ class SelectTenantView(LoginRequiredMixin, View):
|
|||||||
if not tenant_filter_enabled():
|
if not tenant_filter_enabled():
|
||||||
return HttpResponseBadRequest("Der globale Mandantenfilter ist deaktiviert.")
|
return HttpResponseBadRequest("Der globale Mandantenfilter ist deaktiviert.")
|
||||||
|
|
||||||
tenant_id = request.POST.get("tenant")
|
scope_value = request.POST.get("scope") or request.POST.get("tenant", "")
|
||||||
if tenant_id:
|
if not scope_value:
|
||||||
tenant = Tenant.objects.restrict(request.user, "view").filter(pk=tenant_id).first()
|
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:
|
if tenant is None:
|
||||||
return HttpResponseBadRequest("Unbekannter oder nicht erlaubter Mandant.")
|
return HttpResponseBadRequest("Unbekannter oder nicht erlaubter Mandant.")
|
||||||
|
request.session.pop(SESSION_TENANT_GROUP_KEY, None)
|
||||||
request.session[SESSION_TENANT_KEY] = tenant.pk
|
request.session[SESSION_TENANT_KEY] = tenant.pk
|
||||||
messages.success(request, f"Globaler Mandantenfilter: {tenant}")
|
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)
|
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))
|
return redirect(_safe_return_url(request))
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "netbox-utilities"
|
name = "netbox-utilities"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
description = "Personal navigation and a global tenant filter for NetBox 4.6"
|
description = "Personal navigation and a global tenant filter for NetBox 4.6"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
|
|||||||
Reference in New Issue
Block a user