feat: add tenant groups and required tenancy
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"}),
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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:
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 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"])
|
||||
|
||||
@@ -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
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user