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"