feat: add personalized navigation and tenant filtering
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
from netbox.plugins import PluginConfig
|
||||
|
||||
|
||||
class NetBoxUtilitiesConfig(PluginConfig):
|
||||
name = "netbox_utilities"
|
||||
verbose_name = "NetBox Utilities"
|
||||
description = "Personal navigation and a global tenant filter"
|
||||
version = "0.1.0"
|
||||
author = "LKE"
|
||||
base_url = "utilities"
|
||||
min_version = "4.6.5"
|
||||
max_version = "4.6.99"
|
||||
default_settings = {
|
||||
"navigation_customization_enabled": True,
|
||||
"tenant_filter_enabled": True,
|
||||
}
|
||||
middleware = [
|
||||
"netbox_utilities.middleware.GlobalTenantFilterMiddleware",
|
||||
]
|
||||
|
||||
def ready(self):
|
||||
super().ready()
|
||||
from .tenant_scope import install_search_filter
|
||||
|
||||
install_search_filter()
|
||||
|
||||
|
||||
config = NetBoxUtilitiesConfig
|
||||
@@ -0,0 +1,21 @@
|
||||
from django import forms
|
||||
|
||||
from .models import UtilitiesSettings
|
||||
|
||||
|
||||
class UtilitiesSettingsForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = UtilitiesSettings
|
||||
fields = ("tenant_filter_enabled",)
|
||||
labels = {
|
||||
"tenant_filter_enabled": "Globalen Mandantenfilter aktivieren",
|
||||
}
|
||||
help_texts = {
|
||||
"tenant_filter_enabled": (
|
||||
"Blendet das Mandanten-Dropdown ein und ergänzt unterstützte NetBox-Listen "
|
||||
"automatisch um den gewählten Mandantenfilter."
|
||||
),
|
||||
}
|
||||
widgets = {
|
||||
"tenant_filter_enabled": forms.CheckboxInput(attrs={"class": "form-check-input"}),
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
from django.urls import Resolver404, resolve
|
||||
from tenancy.models import Tenant
|
||||
|
||||
from .runtime import tenant_filter_enabled
|
||||
from .tenant_scope import active_tenant_id
|
||||
|
||||
SESSION_TENANT_KEY = "netbox_utilities.selected_tenant_id"
|
||||
|
||||
|
||||
class GlobalTenantFilterMiddleware:
|
||||
"""Apply the selected tenant to tenant-aware NetBox list filtersets."""
|
||||
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
request.netbox_utilities_tenant = None
|
||||
tenant_id = self._get_selected_tenant_id(request)
|
||||
token = active_tenant_id.set(tenant_id)
|
||||
try:
|
||||
if tenant_id is not None and request.method in {"GET", "HEAD"}:
|
||||
self._inject_filter_parameter(request, tenant_id)
|
||||
return self.get_response(request)
|
||||
finally:
|
||||
active_tenant_id.reset(token)
|
||||
|
||||
@staticmethod
|
||||
def _get_selected_tenant_id(request):
|
||||
if not tenant_filter_enabled() or not request.user.is_authenticated:
|
||||
request.session.pop(SESSION_TENANT_KEY, None)
|
||||
return None
|
||||
|
||||
tenant_id = request.session.get(SESSION_TENANT_KEY)
|
||||
if tenant_id is None:
|
||||
return None
|
||||
|
||||
tenant = Tenant.objects.restrict(request.user, "view").filter(pk=tenant_id).first()
|
||||
if tenant is None:
|
||||
request.session.pop(SESSION_TENANT_KEY, None)
|
||||
return None
|
||||
|
||||
request.netbox_utilities_tenant = tenant
|
||||
return tenant.pk
|
||||
|
||||
@staticmethod
|
||||
def _inject_filter_parameter(request, tenant_id):
|
||||
try:
|
||||
match = resolve(request.path_info)
|
||||
except Resolver404:
|
||||
return
|
||||
|
||||
# Plugin, API, login and admin endpoints must never be changed implicitly.
|
||||
if "netbox_utilities" in match.namespaces or "api" in match.namespaces:
|
||||
return
|
||||
|
||||
parameter = None
|
||||
if match.view_name == "tenancy:tenant_list":
|
||||
parameter = "id"
|
||||
else:
|
||||
view_class = getattr(match.func, "view_class", None)
|
||||
filterset = getattr(view_class, "filterset", None)
|
||||
if filterset and "tenant_id" in getattr(filterset, "base_filters", {}):
|
||||
parameter = "tenant_id"
|
||||
|
||||
if parameter:
|
||||
query = request.GET.copy()
|
||||
query.setlist(parameter, [str(tenant_id)])
|
||||
request.GET = query
|
||||
@@ -0,0 +1,54 @@
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="UtilitiesSettings",
|
||||
fields=[
|
||||
(
|
||||
"singleton_id",
|
||||
models.PositiveSmallIntegerField(
|
||||
default=1,
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
validators=[django.core.validators.MinValueValidator(1)],
|
||||
),
|
||||
),
|
||||
("tenant_filter_enabled", models.BooleanField(default=True)),
|
||||
("updated", models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "NetBox Utilities setting",
|
||||
"verbose_name_plural": "NetBox Utilities settings",
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="NavigationPreference",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("menu_order", models.JSONField(blank=True, default=list)),
|
||||
("hidden_menus", models.JSONField(blank=True, default=list)),
|
||||
("updated", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"user",
|
||||
models.OneToOneField(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="netbox_utilities_navigation",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={"ordering": ("user",)},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
from django.conf import settings
|
||||
from django.core.validators import MinValueValidator
|
||||
from django.db import models
|
||||
|
||||
|
||||
class NavigationPreference(models.Model):
|
||||
user = models.OneToOneField(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="netbox_utilities_navigation",
|
||||
)
|
||||
menu_order = models.JSONField(default=list, blank=True)
|
||||
hidden_menus = models.JSONField(default=list, blank=True)
|
||||
updated = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ("user",)
|
||||
|
||||
def __str__(self):
|
||||
return f"Navigation for {self.user}"
|
||||
|
||||
|
||||
class UtilitiesSettings(models.Model):
|
||||
singleton_id = models.PositiveSmallIntegerField(
|
||||
primary_key=True,
|
||||
default=1,
|
||||
editable=False,
|
||||
validators=[MinValueValidator(1)],
|
||||
)
|
||||
tenant_filter_enabled = models.BooleanField(default=True)
|
||||
updated = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "NetBox Utilities setting"
|
||||
verbose_name_plural = "NetBox Utilities settings"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.singleton_id = 1
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return "NetBox Utilities settings"
|
||||
@@ -0,0 +1,14 @@
|
||||
from netbox.plugins import PluginMenuItem
|
||||
|
||||
menu_items = (
|
||||
PluginMenuItem(
|
||||
link="plugins:netbox_utilities:navigation_preferences",
|
||||
link_text="Navigation personalisieren",
|
||||
auth_required=True,
|
||||
),
|
||||
PluginMenuItem(
|
||||
link="plugins:netbox_utilities:settings",
|
||||
link_text="Einstellungen",
|
||||
staff_only=True,
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,50 @@
|
||||
from netbox.navigation.menu import get_menus
|
||||
|
||||
|
||||
def _permitted_menu_items(menu, user):
|
||||
for group in menu.groups:
|
||||
for item in group.items:
|
||||
if getattr(item, "auth_required", False) and not user.is_authenticated:
|
||||
continue
|
||||
if item.staff_only and not user.is_superuser:
|
||||
continue
|
||||
if not user.has_perms(item.permissions):
|
||||
continue
|
||||
yield item
|
||||
|
||||
|
||||
def get_visible_menus(user):
|
||||
"""Return stable descriptors for every sidebar menu visible to the user."""
|
||||
descriptors = []
|
||||
used_keys = set()
|
||||
|
||||
for index, menu in enumerate(get_menus()):
|
||||
items = list(_permitted_menu_items(menu, user))
|
||||
if not items:
|
||||
continue
|
||||
|
||||
first_url = str(items[0].url)
|
||||
key = first_url or menu.icon_class or f"menu-{index}"
|
||||
if key in used_keys:
|
||||
key = f"{key}#{index}"
|
||||
used_keys.add(key)
|
||||
descriptors.append(
|
||||
{
|
||||
"key": key,
|
||||
"label": str(menu.label),
|
||||
"icon_class": menu.icon_class,
|
||||
"first_url": first_url,
|
||||
}
|
||||
)
|
||||
|
||||
return descriptors
|
||||
|
||||
|
||||
def normalize_preferences(menu_order, hidden_menus, available_keys):
|
||||
"""Discard forged/stale keys and append newly introduced menus."""
|
||||
allowed = list(dict.fromkeys(available_keys))
|
||||
allowed_set = set(allowed)
|
||||
order = [key for key in dict.fromkeys(menu_order) if key in allowed_set]
|
||||
order.extend(key for key in allowed if key not in order)
|
||||
hidden = [key for key in dict.fromkeys(hidden_menus) if key in allowed_set]
|
||||
return order, hidden
|
||||
@@ -0,0 +1,33 @@
|
||||
from django.core.cache import cache
|
||||
from django.db import OperationalError, ProgrammingError
|
||||
from netbox.plugins import get_plugin_config
|
||||
|
||||
from .models import UtilitiesSettings
|
||||
|
||||
SETTINGS_CACHE_KEY = "netbox_utilities.runtime_settings.v1"
|
||||
|
||||
|
||||
def navigation_customization_enabled():
|
||||
return bool(get_plugin_config("netbox_utilities", "navigation_customization_enabled"))
|
||||
|
||||
|
||||
def tenant_filter_enabled():
|
||||
if not get_plugin_config("netbox_utilities", "tenant_filter_enabled"):
|
||||
return False
|
||||
|
||||
enabled = cache.get(SETTINGS_CACHE_KEY)
|
||||
if enabled is None:
|
||||
try:
|
||||
enabled = (
|
||||
UtilitiesSettings.objects.filter(singleton_id=1).values_list("tenant_filter_enabled", flat=True).first()
|
||||
)
|
||||
except (OperationalError, ProgrammingError):
|
||||
# The middleware can be imported before the first plugin migration.
|
||||
enabled = True
|
||||
enabled = True if enabled is None else enabled
|
||||
cache.set(SETTINGS_CACHE_KEY, enabled, timeout=30)
|
||||
return bool(enabled)
|
||||
|
||||
|
||||
def clear_runtime_settings_cache():
|
||||
cache.delete(SETTINGS_CACHE_KEY)
|
||||
@@ -0,0 +1,44 @@
|
||||
(() => {
|
||||
'use strict';
|
||||
|
||||
function applyNavigationPreferences() {
|
||||
const dataElement = document.getElementById('netbox-utilities-navigation-data');
|
||||
const sidebar = document.querySelector('#sidebar-menu > ul.navbar-nav');
|
||||
if (!dataElement || !sidebar) return;
|
||||
|
||||
let preferences;
|
||||
try {
|
||||
preferences = JSON.parse(dataElement.textContent);
|
||||
} catch (_error) {
|
||||
return;
|
||||
}
|
||||
|
||||
const descriptorByPath = new Map(
|
||||
preferences.menus.map((menu) => [new URL(menu.first_url, document.baseURI).pathname, menu])
|
||||
);
|
||||
const nodeByKey = new Map();
|
||||
|
||||
sidebar.querySelectorAll(':scope > li.nav-item.dropdown').forEach((node) => {
|
||||
const firstLink = node.querySelector('.dropdown-menu a[href]');
|
||||
if (!firstLink) return;
|
||||
const descriptor = descriptorByPath.get(new URL(firstLink.href, document.baseURI).pathname);
|
||||
if (descriptor) nodeByKey.set(descriptor.key, node);
|
||||
});
|
||||
|
||||
preferences.order.forEach((key) => {
|
||||
const node = nodeByKey.get(key);
|
||||
if (node) sidebar.appendChild(node);
|
||||
});
|
||||
|
||||
const hidden = new Set(preferences.hidden);
|
||||
nodeByKey.forEach((node, key) => {
|
||||
node.classList.toggle('d-none', hidden.has(key));
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', applyNavigationPreferences, {once: true});
|
||||
} else {
|
||||
applyNavigationPreferences();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,11 @@
|
||||
.netbox-utilities-tenant .tenant-filter-menu {
|
||||
min-width: 20rem;
|
||||
}
|
||||
|
||||
.netbox-utilities-tenant .tenant-filter-label {
|
||||
max-width: 14rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
vertical-align: bottom;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
from netbox.plugins import PluginTemplateExtension
|
||||
from tenancy.models import Tenant
|
||||
|
||||
from .models import NavigationPreference
|
||||
from .navigation_helpers import get_visible_menus, normalize_preferences
|
||||
from .runtime import navigation_customization_enabled, tenant_filter_enabled
|
||||
|
||||
|
||||
class UtilitiesGlobalContent(PluginTemplateExtension):
|
||||
def head(self):
|
||||
request = self.context["request"]
|
||||
preference_data = {"order": [], "hidden": [], "menus": []}
|
||||
if request.user.is_authenticated and navigation_customization_enabled():
|
||||
descriptors = get_visible_menus(request.user)
|
||||
preference = NavigationPreference.objects.filter(user=request.user).first()
|
||||
order, hidden = normalize_preferences(
|
||||
preference.menu_order if preference else [],
|
||||
preference.hidden_menus if preference else [],
|
||||
[item["key"] for item in descriptors],
|
||||
)
|
||||
preference_data = {
|
||||
"order": order,
|
||||
"hidden": hidden,
|
||||
"menus": descriptors,
|
||||
}
|
||||
return self.render(
|
||||
"netbox_utilities/head.html",
|
||||
{
|
||||
"navigation_enabled": request.user.is_authenticated and navigation_customization_enabled(),
|
||||
"navigation_preferences": preference_data,
|
||||
},
|
||||
)
|
||||
|
||||
def navbar(self):
|
||||
request = self.context["request"]
|
||||
if not request.user.is_authenticated or not tenant_filter_enabled():
|
||||
return ""
|
||||
|
||||
tenants = Tenant.objects.restrict(request.user, "view").order_by("name")
|
||||
return self.render(
|
||||
"netbox_utilities/tenant_dropdown.html",
|
||||
{
|
||||
"tenants": tenants,
|
||||
"selected_tenant": getattr(request, "netbox_utilities_tenant", None),
|
||||
"return_url": request.get_full_path(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
template_extensions = [UtilitiesGlobalContent]
|
||||
@@ -0,0 +1,6 @@
|
||||
{% load static %}
|
||||
<link rel="stylesheet" href="{% static 'netbox_utilities/netbox_utilities.css' %}">
|
||||
{% if navigation_enabled %}
|
||||
{{ navigation_preferences|json_script:"netbox-utilities-navigation-data" }}
|
||||
<script src="{% static 'netbox_utilities/navigation.js' %}" defer></script>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,88 @@
|
||||
{% extends 'base/layout.html' %}
|
||||
|
||||
{% block title %}Navigation personalisieren{% endblock %}
|
||||
|
||||
{% block header %}
|
||||
<div class="container-fluid mt-3">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<h1>Navigation personalisieren</h1>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock header %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col col-md-10 col-xl-8">
|
||||
<div class="card">
|
||||
<div class="card-header"><h2 class="card-title">Linke Navigation</h2></div>
|
||||
<div class="card-body">
|
||||
{% if not feature_enabled %}
|
||||
<div class="alert alert-warning mb-0">
|
||||
Die Navigationspersonalisierung wurde durch die NetBox-Konfiguration deaktiviert.
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-secondary">
|
||||
Verschiebe Menüs mit den Pfeilen und blende nicht benötigte Bereiche aus.
|
||||
Berechtigungen von NetBox werden dadurch nicht verändert.
|
||||
</p>
|
||||
<form method="post" id="navigation-preferences-form">
|
||||
{% csrf_token %}
|
||||
<div class="list-group mb-3" id="navigation-menu-editor">
|
||||
{% for menu in menus %}
|
||||
<div class="list-group-item d-flex align-items-center gap-3 navigation-menu-row">
|
||||
<input type="hidden" name="menu_order" value="{{ menu.key }}">
|
||||
<i class="{{ menu.icon_class }} fs-2" aria-hidden="true"></i>
|
||||
<span class="flex-fill fw-medium">{{ menu.label }}</span>
|
||||
<label class="form-check form-switch mb-0" title="Menü ausblenden">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
name="hidden_menus"
|
||||
value="{{ menu.key }}"
|
||||
{% if menu.hidden %}checked{% endif %}
|
||||
>
|
||||
<span class="form-check-label">Ausblenden</span>
|
||||
</label>
|
||||
<div class="btn-group" role="group" aria-label="Reihenfolge ändern">
|
||||
<button class="btn btn-sm btn-outline-secondary move-up" type="button" title="Nach oben">
|
||||
<i class="mdi mdi-arrow-up" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary move-down" type="button" title="Nach unten">
|
||||
<i class="mdi mdi-arrow-down" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="alert alert-info mb-0">Für diesen Benutzer sind keine Menüs verfügbar.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="d-flex justify-content-between">
|
||||
<button class="btn btn-outline-danger" type="submit" name="reset" value="1">
|
||||
Zurücksetzen
|
||||
</button>
|
||||
<button class="btn btn-primary" type="submit">Speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(() => {
|
||||
const editor = document.getElementById('navigation-menu-editor');
|
||||
if (!editor) return;
|
||||
editor.addEventListener('click', (event) => {
|
||||
const button = event.target.closest('.move-up, .move-down');
|
||||
if (!button) return;
|
||||
const row = button.closest('.navigation-menu-row');
|
||||
if (button.classList.contains('move-up') && row.previousElementSibling) {
|
||||
editor.insertBefore(row, row.previousElementSibling);
|
||||
}
|
||||
if (button.classList.contains('move-down') && row.nextElementSibling) {
|
||||
editor.insertBefore(row.nextElementSibling, row);
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock content %}
|
||||
@@ -0,0 +1,38 @@
|
||||
{% extends 'base/layout.html' %}
|
||||
|
||||
{% block title %}NetBox Utilities – Einstellungen{% endblock %}
|
||||
|
||||
{% block header %}
|
||||
<div class="container-fluid mt-3"><h1>NetBox Utilities – Einstellungen</h1></div>
|
||||
{% endblock header %}
|
||||
|
||||
{% block content %}
|
||||
<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-body">
|
||||
{% if config_forces_disabled %}
|
||||
<div class="alert alert-warning">
|
||||
Der Mandantenfilter ist in <code>PLUGINS_CONFIG</code> fest deaktiviert und kann hier nicht aktiviert werden.
|
||||
</div>
|
||||
{% endif %}
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
{% if form.non_field_errors %}<div class="alert alert-danger">{{ form.non_field_errors }}</div>{% endif %}
|
||||
<div class="form-check form-switch">
|
||||
{{ form.tenant_filter_enabled }}
|
||||
<label class="form-check-label fw-bold" for="{{ form.tenant_filter_enabled.id_for_label }}">
|
||||
{{ form.tenant_filter_enabled.label }}
|
||||
</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>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock content %}
|
||||
@@ -0,0 +1,44 @@
|
||||
<div class="nav-item dropdown netbox-utilities-tenant">
|
||||
<button
|
||||
class="nav-link px-2"
|
||||
type="button"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false"
|
||||
title="Globaler Mandantenfilter"
|
||||
aria-label="Globaler Mandantenfilter"
|
||||
>
|
||||
<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 %}
|
||||
</span>
|
||||
<i class="mdi mdi-chevron-down" aria-hidden="true"></i>
|
||||
</button>
|
||||
<div class="dropdown-menu dropdown-menu-end p-3 tenant-filter-menu">
|
||||
<form action="{% url 'plugins:netbox_utilities:select_tenant' %}" method="post">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="next" value="{{ return_url }}">
|
||||
<label class="form-label fw-bold">
|
||||
Mandant
|
||||
</label>
|
||||
<select
|
||||
class="form-select"
|
||||
name="tenant"
|
||||
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 %}
|
||||
</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.
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,72 @@
|
||||
from contextvars import ContextVar
|
||||
from functools import wraps
|
||||
|
||||
from tenancy.models import Tenant
|
||||
|
||||
active_tenant_id = ContextVar("netbox_utilities_active_tenant_id", default=None)
|
||||
|
||||
_PARENT_RELATIONS = (
|
||||
"device",
|
||||
"virtual_machine",
|
||||
"site",
|
||||
"location",
|
||||
"rack",
|
||||
"cluster",
|
||||
"circuit",
|
||||
"virtual_circuit",
|
||||
"tunnel",
|
||||
"l2vpn",
|
||||
"wireless_lan",
|
||||
"power_panel",
|
||||
)
|
||||
|
||||
|
||||
def object_matches_tenant(obj, tenant_id, depth=0):
|
||||
"""
|
||||
Return whether a search result belongs to the selected tenant.
|
||||
|
||||
Models without tenant semantics are shared reference data and remain visible.
|
||||
Child objects inherit tenant membership from a known parent relationship.
|
||||
"""
|
||||
if obj is None:
|
||||
return False
|
||||
if isinstance(obj, Tenant):
|
||||
return obj.pk == tenant_id
|
||||
|
||||
field_names = {field.name for field in obj._meta.get_fields()}
|
||||
if "tenant" in field_names:
|
||||
return getattr(obj, "tenant_id", None) == tenant_id
|
||||
|
||||
if depth < 2:
|
||||
for relation in _PARENT_RELATIONS:
|
||||
if relation not in field_names:
|
||||
continue
|
||||
parent = getattr(obj, relation, None)
|
||||
if parent is None:
|
||||
return False
|
||||
return object_matches_tenant(parent, tenant_id, depth + 1)
|
||||
|
||||
# Global reference objects (roles, manufacturers, statuses, etc.) are not
|
||||
# tenant-owned and must remain usable while a filter is active.
|
||||
return True
|
||||
|
||||
|
||||
def install_search_filter():
|
||||
"""Install the tenant scope around NetBox's singleton global search backend."""
|
||||
from netbox.search.backends import search_backend
|
||||
|
||||
if getattr(search_backend, "_netbox_utilities_wrapped", False):
|
||||
return
|
||||
|
||||
original_search = search_backend.search
|
||||
|
||||
@wraps(original_search)
|
||||
def tenant_aware_search(*args, **kwargs):
|
||||
results = original_search(*args, **kwargs)
|
||||
tenant_id = active_tenant_id.get()
|
||||
if tenant_id is None:
|
||||
return results
|
||||
return [result for result in results if object_matches_tenant(result.object, tenant_id)]
|
||||
|
||||
search_backend.search = tenant_aware_search
|
||||
search_backend._netbox_utilities_wrapped = True
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.test import RequestFactory, SimpleTestCase
|
||||
|
||||
from netbox_utilities.middleware import GlobalTenantFilterMiddleware
|
||||
|
||||
|
||||
class TenantFilterSet:
|
||||
base_filters = {"tenant_id": object()}
|
||||
|
||||
|
||||
class SharedFilterSet:
|
||||
base_filters = {"status": object()}
|
||||
|
||||
|
||||
def resolver_match(filterset=None, view_name="dcim:device_list"):
|
||||
view_class = SimpleNamespace(filterset=filterset)
|
||||
return SimpleNamespace(
|
||||
func=SimpleNamespace(view_class=view_class),
|
||||
namespaces=["dcim"],
|
||||
view_name=view_name,
|
||||
)
|
||||
|
||||
|
||||
class GlobalTenantFilterMiddlewareTest(SimpleTestCase):
|
||||
def setUp(self):
|
||||
self.factory = RequestFactory()
|
||||
|
||||
@patch("netbox_utilities.middleware.resolve")
|
||||
def test_injects_and_overrides_tenant_id(self, mocked_resolve):
|
||||
mocked_resolve.return_value = resolver_match(TenantFilterSet)
|
||||
request = self.factory.get("/dcim/devices/?tenant_id=7&status=active")
|
||||
|
||||
GlobalTenantFilterMiddleware._inject_filter_parameter(request, 42)
|
||||
|
||||
self.assertEqual(request.GET.getlist("tenant_id"), ["42"])
|
||||
self.assertEqual(request.GET["status"], "active")
|
||||
|
||||
@patch("netbox_utilities.middleware.resolve")
|
||||
def test_does_not_modify_shared_list(self, mocked_resolve):
|
||||
mocked_resolve.return_value = resolver_match(SharedFilterSet)
|
||||
request = self.factory.get("/dcim/manufacturers/?status=active")
|
||||
|
||||
GlobalTenantFilterMiddleware._inject_filter_parameter(request, 42)
|
||||
|
||||
self.assertNotIn("tenant_id", request.GET)
|
||||
self.assertEqual(request.GET["status"], "active")
|
||||
|
||||
@patch("netbox_utilities.middleware.resolve")
|
||||
def test_filters_tenant_list_by_primary_key(self, mocked_resolve):
|
||||
mocked_resolve.return_value = resolver_match(None, "tenancy:tenant_list")
|
||||
request = self.factory.get("/tenancy/tenants/")
|
||||
|
||||
GlobalTenantFilterMiddleware._inject_filter_parameter(request, 42)
|
||||
|
||||
self.assertEqual(request.GET.getlist("id"), ["42"])
|
||||
@@ -0,0 +1,21 @@
|
||||
from django.test import SimpleTestCase
|
||||
|
||||
from netbox_utilities.navigation_helpers import normalize_preferences
|
||||
|
||||
|
||||
class NormalizePreferencesTest(SimpleTestCase):
|
||||
def test_removes_unknown_and_duplicate_keys(self):
|
||||
order, hidden = normalize_preferences(
|
||||
["devices", "unknown", "devices"],
|
||||
["ipam", "unknown", "ipam"],
|
||||
["organization", "devices", "ipam"],
|
||||
)
|
||||
|
||||
self.assertEqual(order, ["devices", "organization", "ipam"])
|
||||
self.assertEqual(hidden, ["ipam"])
|
||||
|
||||
def test_new_menus_are_appended(self):
|
||||
order, hidden = normalize_preferences(["devices"], [], ["devices", "vpn"])
|
||||
|
||||
self.assertEqual(order, ["devices", "vpn"])
|
||||
self.assertEqual(hidden, [])
|
||||
@@ -0,0 +1,34 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
from django.test import SimpleTestCase
|
||||
|
||||
from netbox_utilities.tenant_scope import object_matches_tenant
|
||||
|
||||
|
||||
class FakeMeta:
|
||||
def __init__(self, *field_names):
|
||||
self.fields = [SimpleNamespace(name=name) for name in field_names]
|
||||
|
||||
def get_fields(self):
|
||||
return self.fields
|
||||
|
||||
|
||||
class TenantScopeTest(SimpleTestCase):
|
||||
def test_direct_tenant_match(self):
|
||||
obj = SimpleNamespace(_meta=FakeMeta("tenant"), tenant_id=42)
|
||||
self.assertTrue(object_matches_tenant(obj, 42))
|
||||
self.assertFalse(object_matches_tenant(obj, 7))
|
||||
|
||||
def test_child_inherits_tenant_from_device(self):
|
||||
device = SimpleNamespace(_meta=FakeMeta("tenant"), tenant_id=42)
|
||||
interface = SimpleNamespace(_meta=FakeMeta("device"), device=device)
|
||||
self.assertTrue(object_matches_tenant(interface, 42))
|
||||
self.assertFalse(object_matches_tenant(interface, 7))
|
||||
|
||||
def test_shared_reference_data_remains_visible(self):
|
||||
manufacturer = SimpleNamespace(_meta=FakeMeta("name"))
|
||||
self.assertTrue(object_matches_tenant(manufacturer, 42))
|
||||
|
||||
def test_unassigned_tenant_object_is_hidden(self):
|
||||
obj = SimpleNamespace(_meta=FakeMeta("tenant"), tenant_id=None)
|
||||
self.assertFalse(object_matches_tenant(obj, 42))
|
||||
@@ -0,0 +1,11 @@
|
||||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
app_name = "netbox_utilities"
|
||||
|
||||
urlpatterns = [
|
||||
path("navigation/", views.NavigationPreferencesView.as_view(), name="navigation_preferences"),
|
||||
path("settings/", views.UtilitiesSettingsView.as_view(), name="settings"),
|
||||
path("tenant/select/", views.SelectTenantView.as_view(), name="select_tenant"),
|
||||
]
|
||||
@@ -0,0 +1,132 @@
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
|
||||
from django.http import HttpResponseBadRequest
|
||||
from django.shortcuts import redirect, render
|
||||
from django.urls import reverse
|
||||
from django.utils.http import url_has_allowed_host_and_scheme
|
||||
from django.views import View
|
||||
from netbox.plugins import get_plugin_config
|
||||
from tenancy.models import Tenant
|
||||
|
||||
from .forms import UtilitiesSettingsForm
|
||||
from .middleware import SESSION_TENANT_KEY
|
||||
from .models import NavigationPreference, UtilitiesSettings
|
||||
from .navigation_helpers import get_visible_menus, normalize_preferences
|
||||
from .runtime import (
|
||||
clear_runtime_settings_cache,
|
||||
navigation_customization_enabled,
|
||||
tenant_filter_enabled,
|
||||
)
|
||||
|
||||
|
||||
def _safe_return_url(request, default_name="home"):
|
||||
candidate = request.POST.get("next") or request.GET.get("next")
|
||||
if candidate and url_has_allowed_host_and_scheme(
|
||||
candidate,
|
||||
allowed_hosts={request.get_host()},
|
||||
require_https=request.is_secure(),
|
||||
):
|
||||
return candidate
|
||||
return reverse(default_name)
|
||||
|
||||
|
||||
class NavigationPreferencesView(LoginRequiredMixin, View):
|
||||
template_name = "netbox_utilities/navigation_preferences.html"
|
||||
|
||||
def get(self, request):
|
||||
return self._render(request)
|
||||
|
||||
def post(self, request):
|
||||
if not navigation_customization_enabled():
|
||||
return HttpResponseBadRequest("Die Navigationspersonalisierung ist deaktiviert.")
|
||||
|
||||
preference, _ = NavigationPreference.objects.get_or_create(user=request.user)
|
||||
if "reset" in request.POST:
|
||||
preference.menu_order = []
|
||||
preference.hidden_menus = []
|
||||
else:
|
||||
descriptors = get_visible_menus(request.user)
|
||||
available_keys = [item["key"] for item in descriptors]
|
||||
order, hidden = normalize_preferences(
|
||||
request.POST.getlist("menu_order"),
|
||||
request.POST.getlist("hidden_menus"),
|
||||
available_keys,
|
||||
)
|
||||
preference.menu_order = order
|
||||
preference.hidden_menus = hidden
|
||||
preference.save()
|
||||
messages.success(request, "Die persönliche Navigation wurde gespeichert.")
|
||||
return redirect("plugins:netbox_utilities:navigation_preferences")
|
||||
|
||||
def _render(self, request):
|
||||
descriptors = get_visible_menus(request.user)
|
||||
preference = NavigationPreference.objects.filter(user=request.user).first()
|
||||
order, hidden = normalize_preferences(
|
||||
preference.menu_order if preference else [],
|
||||
preference.hidden_menus if preference else [],
|
||||
[item["key"] for item in descriptors],
|
||||
)
|
||||
by_key = {item["key"]: item for item in descriptors}
|
||||
menus = [{**by_key[key], "hidden": key in hidden} for key in order]
|
||||
return render(
|
||||
request,
|
||||
self.template_name,
|
||||
{
|
||||
"menus": menus,
|
||||
"feature_enabled": navigation_customization_enabled(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class UtilitiesSettingsView(LoginRequiredMixin, UserPassesTestMixin, View):
|
||||
template_name = "netbox_utilities/settings.html"
|
||||
raise_exception = True
|
||||
|
||||
def test_func(self):
|
||||
return self.request.user.is_superuser
|
||||
|
||||
def get(self, request):
|
||||
instance, _ = UtilitiesSettings.objects.get_or_create(singleton_id=1)
|
||||
return render(
|
||||
request,
|
||||
self.template_name,
|
||||
{
|
||||
"form": UtilitiesSettingsForm(instance=instance),
|
||||
"config_forces_disabled": not get_plugin_config("netbox_utilities", "tenant_filter_enabled"),
|
||||
},
|
||||
)
|
||||
|
||||
def post(self, request):
|
||||
if not get_plugin_config("netbox_utilities", "tenant_filter_enabled"):
|
||||
return HttpResponseBadRequest("Der Mandantenfilter ist in PLUGINS_CONFIG deaktiviert.")
|
||||
instance, _ = UtilitiesSettings.objects.get_or_create(singleton_id=1)
|
||||
form = UtilitiesSettingsForm(request.POST, instance=instance)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
clear_runtime_settings_cache()
|
||||
if not form.cleaned_data["tenant_filter_enabled"]:
|
||||
request.session.pop(SESSION_TENANT_KEY, None)
|
||||
messages.success(request, "Die Plugin-Einstellungen wurden gespeichert.")
|
||||
return redirect("plugins:netbox_utilities:settings")
|
||||
return render(request, self.template_name, {"form": form})
|
||||
|
||||
|
||||
class SelectTenantView(LoginRequiredMixin, View):
|
||||
http_method_names = ["post"]
|
||||
|
||||
def post(self, request):
|
||||
if not tenant_filter_enabled():
|
||||
return HttpResponseBadRequest("Der globale Mandantenfilter ist deaktiviert.")
|
||||
|
||||
tenant_id = request.POST.get("tenant")
|
||||
if tenant_id:
|
||||
tenant = Tenant.objects.restrict(request.user, "view").filter(pk=tenant_id).first()
|
||||
if tenant is None:
|
||||
return HttpResponseBadRequest("Unbekannter oder nicht erlaubter Mandant.")
|
||||
request.session[SESSION_TENANT_KEY] = tenant.pk
|
||||
messages.success(request, f"Globaler Mandantenfilter: {tenant}")
|
||||
else:
|
||||
request.session.pop(SESSION_TENANT_KEY, None)
|
||||
messages.success(request, "Der globale Mandantenfilter wurde aufgehoben.")
|
||||
|
||||
return redirect(_safe_return_url(request))
|
||||
Reference in New Issue
Block a user