diff --git a/README.md b/README.md index bfa7057..e395daf 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,11 @@ mehr vom Ladezeitpunkt des Browseradapters abhängig. Für die optionale Topology-Rack-Ansicht wird die Teilbreitengeometrie zusätzlich als serverseitiges CSS ausgegeben; JavaScript wird dort nur noch für Exporte und ergänzende Metadaten benötigt. +Ab Version `0.9.6` verwendet Reorder direkt den bereits durch seinen View +autorisierten Rack-Datensatz. Die Topology-Rack-Ansicht leitet ihre Breiten +ausschließlich aus den Geräten ab, die der Topology-View tatsächlich in seine +HTML-Antwort geschrieben hat. Zusätzliche Berechtigungsabfragen können die +beiden Ansichten dadurch nicht mehr fälschlich leeren. Bereits vorhandene Geräte, die dieselbe HE und Rackseite belegen, aber noch keine Plugin-Platzierungszeile besitzen, werden in Rack-SVG, Reorder und der Topology-Rack-Ansicht ohne Datenbankänderung gleichmäßig nebeneinander diff --git a/netbox_utilities/__init__.py b/netbox_utilities/__init__.py index a91de06..2bd9433 100644 --- a/netbox_utilities/__init__.py +++ b/netbox_utilities/__init__.py @@ -1,6 +1,6 @@ from netbox.plugins import PluginConfig, get_plugin_config -__version__ = "0.9.5" +__version__ = "0.9.6" class NetBoxUtilitiesConfig(PluginConfig): @@ -38,6 +38,10 @@ class NetBoxUtilitiesConfig(PluginConfig): from .reorder_rack import install_reorder_rack_bulk_save install_reorder_rack_bulk_save() + if get_plugin_config("netbox_utilities", "topology_views_rack_width_enabled"): + from .topology_views import install_topology_rack_width_support + + install_topology_rack_width_support() config = NetBoxUtilitiesConfig diff --git a/netbox_utilities/reorder_rack.py b/netbox_utilities/reorder_rack.py index 1b676a7..6ef73c7 100644 --- a/netbox_utilities/reorder_rack.py +++ b/netbox_utilities/reorder_rack.py @@ -15,6 +15,7 @@ from rest_framework.exceptions import PermissionDenied from rest_framework.response import Response from utilities.permissions import get_permission_for_model +from . import __version__ from .models import DeviceRackPlacement from .rack_width import FULL_WIDTH, effective_width_positions, normalize_width_position, stage_width_position @@ -256,13 +257,17 @@ def _empty_reorder_rack_width_data(request, *, status="ready"): } -def get_reorder_rack_width_data(request): +def get_reorder_rack_width_data(request, *, rack=None, devices=None): """Describe every mounted widget for netbox-reorder-rack's GridStack UI.""" - if not reorder_rack_width_enabled(request): + # The patched Reorder view passes its already-authorized rack and device + # queryset explicitly. The global helper keeps the permission-scoped + # lookup for callers which do not own the rendered view. + view_owned_data = rack is not None + if not view_owned_data and not reorder_rack_width_enabled(request): return None data = _empty_reorder_rack_width_data(request) - rack_id = getattr(request.resolver_match, "kwargs", {}).get("pk") + rack_id = rack.pk if rack is not None else getattr(request.resolver_match, "kwargs", {}).get("pk") if not rack_id: data["status"] = "missing-rack" return data @@ -271,25 +276,27 @@ def get_reorder_rack_width_data(request): from utilities.html import foreground_color try: - rack = Rack.objects.restrict(request.user, "view").filter(pk=rack_id).first() + if rack is None: + rack = Rack.objects.restrict(request.user, "view").filter(pk=rack_id).first() if rack is None: return None - devices = ( - Device.objects.restrict(request.user, "view") - .filter( - rack=rack, - position__gt=0, - device_type__u_height__gt=0, + if devices is None: + devices = ( + Device.objects.restrict(request.user, "view") + .filter( + rack=rack, + position__gt=0, + device_type__u_height__gt=0, + ) + .select_related( + "device_type", + "device_type__manufacturer", + "role", + "virtual_chassis", + "netbox_utilities_rack_placement", + ) + .order_by("position", "pk") ) - .select_related( - "device_type", - "device_type__manufacturer", - "role", - "virtual_chassis", - "netbox_utilities_rack_placement", - ) - .order_by("position", "pk") - ) devices = list(devices) widths = effective_width_positions(devices) permission = get_permission_for_model(Device, "change") @@ -410,7 +417,22 @@ def width_aware_reorder_get(self, request, pk): .exclude(device_type__subdevice_role=SubdeviceRoleChoices.ROLE_CHILD) .select_related("device_type", "role") ) - width_data = get_reorder_rack_width_data(request) + mounted_devices = ( + Device.objects.filter( + rack=rack, + position__gt=0, + device_type__u_height__gt=0, + ) + .select_related( + "device_type", + "device_type__manufacturer", + "role", + "virtual_chassis", + "netbox_utilities_rack_placement", + ) + .order_by("position", "pk") + ) + width_data = get_reorder_rack_width_data(request, rack=rack, devices=mounted_devices) if not width_data or not width_data.get("complete"): original_get = getattr(type(self), "_netbox_utilities_original_get", None) if original_get is not None: @@ -426,8 +448,10 @@ def width_aware_reorder_get(self, request, pk): "labels": labels, "unit_width": width_data["unit_width"], "reorder_devices": width_data["devices"], + "reorder_rack_width_data": width_data, "non_racked": non_racked, "basepath": settings.BASE_PATH, + "asset_version": __version__, }, ) diff --git a/netbox_utilities/template_content.py b/netbox_utilities/template_content.py index 3852fe0..d4ef5b1 100644 --- a/netbox_utilities/template_content.py +++ b/netbox_utilities/template_content.py @@ -14,16 +14,12 @@ from .navigation_helpers import ( normalize_preferences, normalize_sidebar_width, ) -from .reorder_rack import get_reorder_rack_width_data from .runtime import navigation_customization_enabled, tenant_filter_enabled -from .topology_views import get_topology_rack_width_data class UtilitiesGlobalContent(PluginTemplateExtension): def head(self): request = self.context["request"] - reorder_rack_width_data = get_reorder_rack_width_data(request) - topology_rack_width_data = get_topology_rack_width_data(request) preference_data = { "order": [], "hidden": [], @@ -60,8 +56,6 @@ class UtilitiesGlobalContent(PluginTemplateExtension): }, "navigation_enabled": request.user.is_authenticated and navigation_customization_enabled(), "navigation_preferences": preference_data, - "reorder_rack_width_data": reorder_rack_width_data, - "topology_rack_width_data": topology_rack_width_data, "asset_version": __version__, }, ) diff --git a/netbox_utilities/templates/netbox_utilities/head.html b/netbox_utilities/templates/netbox_utilities/head.html index 8e20047..1463e68 100644 --- a/netbox_utilities/templates/netbox_utilities/head.html +++ b/netbox_utilities/templates/netbox_utilities/head.html @@ -8,22 +8,3 @@ {{ navigation_preferences|json_script:"netbox-utilities-navigation-data" }} {% endif %} -{% if topology_rack_width_data %} - {% if topology_rack_width_data.complete %} - - {% endif %} - {{ topology_rack_width_data|json_script:"netbox-utilities-topology-rack-width-data" }} - -{% endif %} -{% if reorder_rack_width_data %} - {{ reorder_rack_width_data|json_script:"netbox-utilities-reorder-rack-width-data" }} - -{% endif %} diff --git a/netbox_utilities/templates/netbox_utilities/reorder_rack.html b/netbox_utilities/templates/netbox_utilities/reorder_rack.html index a3eb818..d24ed51 100644 --- a/netbox_utilities/templates/netbox_utilities/reorder_rack.html +++ b/netbox_utilities/templates/netbox_utilities/reorder_rack.html @@ -2,6 +2,13 @@ {% load perms %} {% load rack %} {% load i18n %} +{% load static %} + +{% block head %} + {{ block.super }} + {{ reorder_rack_width_data|json_script:"netbox-utilities-reorder-rack-width-data" }} + +{% endblock %} {% block content %}
diff --git a/netbox_utilities/tests/test_optional_integration_pages.py b/netbox_utilities/tests/test_optional_integration_pages.py index 8da4714..e51252b 100644 --- a/netbox_utilities/tests/test_optional_integration_pages.py +++ b/netbox_utilities/tests/test_optional_integration_pages.py @@ -1,5 +1,3 @@ -from unittest.mock import patch - from django.conf import settings from django.contrib.auth.context_processors import PermWrapper from django.contrib.auth.models import AnonymousUser @@ -9,75 +7,17 @@ from netbox_utilities.template_content import UtilitiesGlobalContent class OptionalRackIntegrationHeadTest(SimpleTestCase): - @staticmethod - def _context(path): - request = RequestFactory().get(path) + def test_foreign_rack_adapters_are_not_loaded_by_the_global_head(self): + request = RequestFactory().get("/dcim/racks/3/reorder/") request.user = AnonymousUser() - return { - "request": request, - "settings": settings, - "csrf_token": "", - "perms": PermWrapper(request.user), - } + html = UtilitiesGlobalContent( + { + "request": request, + "settings": settings, + "csrf_token": "", + "perms": PermWrapper(request.user), + } + ).head() - def test_reorder_adapter_is_emitted_for_an_empty_enabled_payload(self): - data = { - "columns": 12, - "unit_width": 220, - "images": True, - "labels": True, - "devices": [], - "status": "ready", - "schema_version": 3, - "complete": True, - } - with ( - patch("netbox_utilities.template_content.get_reorder_rack_width_data", return_value=data), - patch("netbox_utilities.template_content.get_topology_rack_width_data", return_value=None), - ): - html = UtilitiesGlobalContent(self._context("/dcim/racks/3/reorder/")).head() - - self.assertIn('id="netbox-utilities-reorder-rack-width-data"', html) - self.assertIn("netbox_utilities/reorder-rack-width.js", html) - self.assertIn('"status": "ready"', html) - - def test_topology_adapter_is_emitted_for_an_empty_enabled_payload(self): - data = {"devices": [], "status": "ready", "schema_version": 3, "complete": True} - with ( - patch("netbox_utilities.template_content.get_reorder_rack_width_data", return_value=None), - patch("netbox_utilities.template_content.get_topology_rack_width_data", return_value=data), - ): - html = UtilitiesGlobalContent( - self._context("/plugins/netbox_topology_views/rack-elevation/?rack_id=3") - ).head() - - self.assertIn('id="netbox-utilities-topology-rack-width-data"', html) - self.assertIn("netbox_utilities/topology-rack-width.js", html) - self.assertIn('"status": "ready"', html) - - def test_topology_widths_are_rendered_server_side_without_javascript(self): - data = { - "devices": [ - { - "device_id": 17, - "url": "/dcim/devices/17/", - "left_percent": 50, - "width_percent": 50, - } - ], - "status": "ready", - "schema_version": 3, - "complete": True, - } - with ( - patch("netbox_utilities.template_content.get_reorder_rack_width_data", return_value=None), - patch("netbox_utilities.template_content.get_topology_rack_width_data", return_value=data), - ): - html = UtilitiesGlobalContent( - self._context("/plugins/netbox_topology_views/rack-elevation/?rack_id=3") - ).head() - - self.assertIn('id="netbox-utilities-topology-rack-width-styles"', html) - self.assertIn('.rack-device[href="/dcim/devices/17/"]', html) - self.assertIn("left: calc(50% + 3px) !important", html) - self.assertIn("width: calc(50% - 6px) !important", html) + self.assertNotIn("netbox-utilities-reorder-rack-width-data", html) + self.assertNotIn("netbox-utilities-topology-rack-width-data", html) diff --git a/netbox_utilities/tests/test_reorder_rack.py b/netbox_utilities/tests/test_reorder_rack.py index 98add6c..f8a1827 100644 --- a/netbox_utilities/tests/test_reorder_rack.py +++ b/netbox_utilities/tests/test_reorder_rack.py @@ -310,6 +310,8 @@ class ReorderRackFrontendTest(SimpleTestCase): self.assertIn("{% for device in reorder_devices %}", grid) self.assertIn('gs-w="{{ device.grid_width }}"', grid) self.assertIn('gs-x="{{ device.grid_x }}"', grid) + self.assertIn('json_script:"netbox-utilities-reorder-rack-width-data"', page) + self.assertIn("netbox_utilities/reorder-rack-width.js", page) get_template("netbox_utilities/reorder_rack.html") rendered_grid = render_to_string( @@ -403,6 +405,45 @@ class ReorderRackFrontendTest(SimpleTestCase): self.assertEqual(template_name, "netbox_utilities/reorder_rack.html") self.assertEqual(context["reorder_devices"][0]["label"], "LEO-Fritzbox") self.assertEqual(context["reorder_devices"][0]["grid_width"], 6) + self.assertEqual(context["asset_version"], "0.9.6") + self.assertIs(context["reorder_rack_width_data"], get_width_data.return_value) + get_width_data.assert_called_once() + self.assertIs(get_width_data.call_args.kwargs["rack"], rack) + self.assertIsNotNone(get_width_data.call_args.kwargs["devices"]) + + @patch("netbox_utilities.reorder_rack.get_permission_for_model", return_value="dcim.change_device") + def test_view_owned_rack_data_does_not_repeat_route_or_object_filtering(self, _get_permission): + request = RequestFactory().get("/dcim/racks/3/reorder/") + request.user = MagicMock() + request.user.has_perm.return_value = True + rack = SimpleNamespace(pk=3, u_height=42, desc_units=False) + device = SimpleNamespace( + pk=334, + rack_id=3, + label="LEO-Fritzbox", + device_type=SimpleNamespace( + u_height=1, + is_full_depth=False, + front_image=None, + rear_image=None, + ), + role=SimpleNamespace(color="f0a000"), + face="front", + position=Decimal(11), + netbox_utilities_rack_placement=SimpleNamespace(width=2, horizontal_position=1), + ) + + with ( + patch("netbox_utilities.reorder_rack.reorder_rack_width_enabled") as enabled, + patch("netbox.config.get_config", return_value=SimpleNamespace(RACK_ELEVATION_DEFAULT_UNIT_WIDTH=220)), + patch("utilities.html.foreground_color", return_value="000000"), + ): + result = get_reorder_rack_width_data(request, rack=rack, devices=[device]) + + enabled.assert_not_called() + self.assertTrue(result["complete"]) + self.assertEqual(result["devices"][0]["id"], 334) + self.assertEqual(result["devices"][0]["grid_width"], 6) @patch("netbox_utilities.reorder_rack.reorder_rack_width_enabled", return_value=True) @patch("netbox_utilities.reorder_rack.Rack") diff --git a/netbox_utilities/tests/test_topology_views.py b/netbox_utilities/tests/test_topology_views.py index b858340..f096775 100644 --- a/netbox_utilities/tests/test_topology_views.py +++ b/netbox_utilities/tests/test_topology_views.py @@ -2,10 +2,15 @@ from pathlib import Path from types import SimpleNamespace from unittest.mock import MagicMock, patch +from django.http import HttpResponse from django.test import RequestFactory, SimpleTestCase from netbox_utilities.topology_views import ( + _inject_topology_widths, + _topology_data_for_visible_devices, + _visible_topology_device_urls, get_topology_rack_width_data, + install_topology_rack_width_support, is_topology_rack_elevation_request, serialize_topology_placement, topology_rack_width_enabled, @@ -156,3 +161,108 @@ class TopologyViewsRackWidthTest(SimpleTestCase): self.assertIn(".rack-device.netbox-utilities-partial-width", stylesheet) self.assertIn("--netbox-utilities-rack-device-left", stylesheet) self.assertIn("--netbox-utilities-rack-device-width", stylesheet) + + def test_extracts_only_devices_rendered_by_the_authorized_topology_view(self): + html = """ + Fritzbox + Grandstream + Not a rack device + """ + + self.assertEqual( + _visible_topology_device_urls(html), + {334: "/dcim/devices/334/", 340: "/dcim/devices/340/"}, + ) + + @patch("dcim.models.Device") + def test_builds_both_stored_half_widths_from_visible_he11_devices(self, device_model): + devices = [ + SimpleNamespace( + pk=334, + rack_id=3, + face="front", + position=11, + device_type=SimpleNamespace(u_height=1), + netbox_utilities_rack_placement=SimpleNamespace(width=2, horizontal_position=1), + get_absolute_url=lambda: "/dcim/devices/334/", + ), + SimpleNamespace( + pk=340, + rack_id=3, + face="front", + position=11, + device_type=SimpleNamespace(u_height=1), + netbox_utilities_rack_placement=SimpleNamespace(width=2, horizontal_position=2), + get_absolute_url=lambda: "/dcim/devices/340/", + ), + ] + device_model.objects.filter.return_value.select_related.return_value.order_by.return_value = devices + + result = _topology_data_for_visible_devices({334: "/dcim/devices/334/", 340: "/dcim/devices/340/"}) + + self.assertTrue(result["complete"]) + self.assertEqual( + [(item["device_id"], item["width"], item["horizontal_position"]) for item in result["devices"]], + [(334, 2, 1), (340, 2, 2)], + ) + + @patch("netbox_utilities.topology_views._topology_data_for_visible_devices") + def test_injects_stored_widths_into_the_completed_foreign_response(self, get_data): + get_data.return_value = { + "devices": [ + { + "device_id": 334, + "url": "/dcim/devices/334/", + "width": 2, + "horizontal_position": 1, + "width_source": "stored", + "left_percent": 0, + "width_percent": 50, + }, + { + "device_id": 340, + "url": "/dcim/devices/340/", + "width": 2, + "horizontal_position": 2, + "width_source": "stored", + "left_percent": 50, + "width_percent": 50, + }, + ], + "status": "ready", + "schema_version": 3, + "complete": True, + } + response = HttpResponse( + 'A' + 'B' + ) + + result = _inject_topology_widths(response) + html = result.content.decode() + + get_data.assert_called_once_with({334: "/dcim/devices/334/", 340: "/dcim/devices/340/"}) + self.assertIn('id="netbox-utilities-topology-rack-width-styles"', html) + self.assertIn('.rack-device[href="/dcim/devices/334/"]', html) + self.assertIn("left: calc(50% + 3px) !important", html) + self.assertIn("netbox_utilities/topology-rack-width.js?v=0.9.6", html) + + def test_patches_the_optional_topology_view_only_once(self): + def original_get(self, request): + return self, request + + class RackElevationView: + get = original_get + + module = SimpleNamespace(RackElevationView=RackElevationView) + with ( + patch("netbox_utilities.topology_views.apps.is_installed", return_value=True), + patch("netbox_utilities.topology_views.get_plugin_config", return_value=True), + patch("netbox_utilities.topology_views.import_module", return_value=module), + ): + self.assertTrue(install_topology_rack_width_support()) + patched_get = RackElevationView.get + self.assertTrue(install_topology_rack_width_support()) + + self.assertIs(RackElevationView.get, patched_get) + self.assertIs(RackElevationView._netbox_utilities_original_get, original_get) diff --git a/netbox_utilities/topology_views.py b/netbox_utilities/topology_views.py index 6eb0cfc..0e939b0 100644 --- a/netbox_utilities/topology_views.py +++ b/netbox_utilities/topology_views.py @@ -1,15 +1,29 @@ import logging +import re +from functools import wraps +from html import unescape +from importlib import import_module from django.apps import apps from django.core.exceptions import ObjectDoesNotExist from django.db import OperationalError, ProgrammingError +from django.templatetags.static import static +from django.utils.html import escape, json_script from netbox.plugins import get_plugin_config +from . import __version__ from .rack_width import effective_width_positions logger = logging.getLogger(__name__) TOPOLOGY_RACK_ELEVATION_VIEW = "plugins:netbox_topology_views:rack_elevation" +TOPOLOGY_VIEW_PATCH_MARKER = "_netbox_utilities_rack_width_view" +RACK_DEVICE_TAG_RE = re.compile( + r"]*\bclass=[\"'][^\"']*\brack-device\b[^\"']*[\"'][^>]*>", + re.IGNORECASE, +) +HREF_RE = re.compile(r"\bhref=[\"'](?P[^\"']+)[\"']", re.IGNORECASE) +DEVICE_ID_RE = re.compile(r"(?:^|/)dcim/devices/(?P\d+)/?(?:$|[?#])") def is_topology_rack_elevation_request(request): @@ -108,3 +122,121 @@ def get_topology_rack_width_data(request): ) data["status"] = "native-fallback" return data + + +def _visible_topology_device_urls(html): + """Return device URLs which the authorized Topology response actually contains.""" + urls = {} + for tag_match in RACK_DEVICE_TAG_RE.finditer(html): + href_match = HREF_RE.search(tag_match.group(0)) + if href_match is None: + continue + url = unescape(href_match.group("url")) + device_match = DEVICE_ID_RE.search(url) + if device_match is not None: + urls[int(device_match.group("device_id"))] = url + return urls + + +def _topology_data_for_visible_devices(visible_urls): + """Build widths only for devices already exposed by the foreign view.""" + from dcim.models import Device + + devices = list( + Device.objects.filter(pk__in=visible_urls) + .select_related("device_type", "netbox_utilities_rack_placement") + .order_by("rack_id", "face", "position", "pk") + ) + widths = effective_width_positions(devices) + result = {"devices": [], "status": "ready", "schema_version": 3, "complete": True} + for device in devices: + width, position, source = widths[device.pk] + if width <= 1: + continue + descriptor = serialize_topology_width(device, width, position, source=source) + descriptor["url"] = visible_urls[device.pk] + result["devices"].append(descriptor) + return result + + +def _css_string(value): + return str(value).replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\a ") + + +def _topology_width_head(data): + rules = [] + for device in data["devices"]: + rules.append( + f'.rack-device[href="{_css_string(device["url"])}"] {{' + " right: auto !important;" + f" left: calc({device['left_percent']}% + 3px) !important;" + f" width: calc({device['width_percent']}% - 6px) !important;" + " }" + ) + stylesheet = f'' + payload = json_script(data, "netbox-utilities-topology-rack-width-data") + script_url = escape(f"{static('netbox_utilities/topology-rack-width.js')}?v={__version__}") + return f'{stylesheet}{payload}' + + +def _inject_topology_widths(response): + if ( + getattr(response, "streaming", False) + or getattr(response, "status_code", None) != 200 + or "text/html" not in response.get("Content-Type", "") + ): + return response + + html = response.content.decode(response.charset or "utf-8") + if "" not in html.lower() or "netbox-utilities-topology-rack-width-data" in html: + return response + visible_urls = _visible_topology_device_urls(html) + data = _topology_data_for_visible_devices(visible_urls) + insertion = _topology_width_head(data) + head_end = html.lower().index("") + html = f"{html[:head_end]}{insertion}{html[head_end:]}" + content = html.encode(response.charset or "utf-8") + response.content = content + if response.has_header("Content-Length"): + response["Content-Length"] = str(len(content)) + return response + + +def install_topology_rack_width_support(): + """Patch the optional view and derive widths from its authorized HTML.""" + if not ( + apps.is_installed("netbox_topology_views") + and get_plugin_config("netbox_utilities", "topology_views_rack_width_enabled") + ): + return False + + try: + topology_views = import_module("netbox_topology_views.views") + view = topology_views.RackElevationView + except (AttributeError, ImportError): + logger.warning("NetBox Utilities could not find the optional Topology rack elevation view") + return False + + if getattr(view, TOPOLOGY_VIEW_PATCH_MARKER, False): + return True + + original_get = view.get + + @wraps(original_get) + def width_aware_topology_get(self, request, *args, **kwargs): + response = original_get(self, request, *args, **kwargs) + try: + return _inject_topology_widths(response) + except (AttributeError, ObjectDoesNotExist, OperationalError, ProgrammingError, TypeError, ValueError): + logger.warning( + "Could not inject partial rack widths into NetBox Topology Views path %s", + request.get_full_path(), + exc_info=True, + ) + return response + + view._netbox_utilities_original_get = original_get + view.get = width_aware_topology_get + setattr(view, TOPOLOGY_VIEW_PATCH_MARKER, True) + logger.info("Enabled partial rack widths for the optional Topology rack elevation view") + return True diff --git a/pyproject.toml b/pyproject.toml index d0740e9..d2e952c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "netbox-utilities" -version = "0.9.5" +version = "0.9.6" description = "Navigation, tenant utilities, partial-width rack devices, bulk uploads, and rack reordering for NetBox 4.6" readme = "README.md" requires-python = ">=3.12"