fix: bind rack integrations to rendered devices

This commit is contained in:
2026-08-13 10:51:59 +02:00
parent 270d821219
commit e42580d651
11 changed files with 357 additions and 119 deletions
+5
View File
@@ -127,6 +127,11 @@ mehr vom Ladezeitpunkt des Browseradapters abhängig. Für die optionale
Topology-Rack-Ansicht wird die Teilbreitengeometrie zusätzlich als Topology-Rack-Ansicht wird die Teilbreitengeometrie zusätzlich als
serverseitiges CSS ausgegeben; JavaScript wird dort nur noch für Exporte und serverseitiges CSS ausgegeben; JavaScript wird dort nur noch für Exporte und
ergänzende Metadaten benötigt. 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 Bereits vorhandene Geräte, die dieselbe HE und Rackseite belegen, aber noch
keine Plugin-Platzierungszeile besitzen, werden in Rack-SVG, Reorder und der keine Plugin-Platzierungszeile besitzen, werden in Rack-SVG, Reorder und der
Topology-Rack-Ansicht ohne Datenbankänderung gleichmäßig nebeneinander Topology-Rack-Ansicht ohne Datenbankänderung gleichmäßig nebeneinander
+5 -1
View File
@@ -1,6 +1,6 @@
from netbox.plugins import PluginConfig, get_plugin_config from netbox.plugins import PluginConfig, get_plugin_config
__version__ = "0.9.5" __version__ = "0.9.6"
class NetBoxUtilitiesConfig(PluginConfig): class NetBoxUtilitiesConfig(PluginConfig):
@@ -38,6 +38,10 @@ class NetBoxUtilitiesConfig(PluginConfig):
from .reorder_rack import install_reorder_rack_bulk_save from .reorder_rack import install_reorder_rack_bulk_save
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 config = NetBoxUtilitiesConfig
+44 -20
View File
@@ -15,6 +15,7 @@ from rest_framework.exceptions import PermissionDenied
from rest_framework.response import Response from rest_framework.response import Response
from utilities.permissions import get_permission_for_model from utilities.permissions import get_permission_for_model
from . import __version__
from .models import DeviceRackPlacement from .models import DeviceRackPlacement
from .rack_width import FULL_WIDTH, effective_width_positions, normalize_width_position, stage_width_position 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.""" """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 return None
data = _empty_reorder_rack_width_data(request) 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: if not rack_id:
data["status"] = "missing-rack" data["status"] = "missing-rack"
return data return data
@@ -271,25 +276,27 @@ def get_reorder_rack_width_data(request):
from utilities.html import foreground_color from utilities.html import foreground_color
try: 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: if rack is None:
return None return None
devices = ( if devices is None:
Device.objects.restrict(request.user, "view") devices = (
.filter( Device.objects.restrict(request.user, "view")
rack=rack, .filter(
position__gt=0, rack=rack,
device_type__u_height__gt=0, 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) devices = list(devices)
widths = effective_width_positions(devices) widths = effective_width_positions(devices)
permission = get_permission_for_model(Device, "change") 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) .exclude(device_type__subdevice_role=SubdeviceRoleChoices.ROLE_CHILD)
.select_related("device_type", "role") .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"): if not width_data or not width_data.get("complete"):
original_get = getattr(type(self), "_netbox_utilities_original_get", None) original_get = getattr(type(self), "_netbox_utilities_original_get", None)
if original_get is not None: if original_get is not None:
@@ -426,8 +448,10 @@ def width_aware_reorder_get(self, request, pk):
"labels": labels, "labels": labels,
"unit_width": width_data["unit_width"], "unit_width": width_data["unit_width"],
"reorder_devices": width_data["devices"], "reorder_devices": width_data["devices"],
"reorder_rack_width_data": width_data,
"non_racked": non_racked, "non_racked": non_racked,
"basepath": settings.BASE_PATH, "basepath": settings.BASE_PATH,
"asset_version": __version__,
}, },
) )
-6
View File
@@ -14,16 +14,12 @@ from .navigation_helpers import (
normalize_preferences, normalize_preferences,
normalize_sidebar_width, normalize_sidebar_width,
) )
from .reorder_rack import get_reorder_rack_width_data
from .runtime import navigation_customization_enabled, tenant_filter_enabled from .runtime import navigation_customization_enabled, tenant_filter_enabled
from .topology_views import get_topology_rack_width_data
class UtilitiesGlobalContent(PluginTemplateExtension): class UtilitiesGlobalContent(PluginTemplateExtension):
def head(self): def head(self):
request = self.context["request"] 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 = { preference_data = {
"order": [], "order": [],
"hidden": [], "hidden": [],
@@ -60,8 +56,6 @@ class UtilitiesGlobalContent(PluginTemplateExtension):
}, },
"navigation_enabled": request.user.is_authenticated and navigation_customization_enabled(), "navigation_enabled": request.user.is_authenticated and navigation_customization_enabled(),
"navigation_preferences": preference_data, "navigation_preferences": preference_data,
"reorder_rack_width_data": reorder_rack_width_data,
"topology_rack_width_data": topology_rack_width_data,
"asset_version": __version__, "asset_version": __version__,
}, },
) )
@@ -8,22 +8,3 @@
{{ navigation_preferences|json_script:"netbox-utilities-navigation-data" }} {{ navigation_preferences|json_script:"netbox-utilities-navigation-data" }}
<script src="{% static 'netbox_utilities/navigation.js' %}?v={{ asset_version }}" defer></script> <script src="{% static 'netbox_utilities/navigation.js' %}?v={{ asset_version }}" defer></script>
{% endif %} {% endif %}
{% if topology_rack_width_data %}
{% if topology_rack_width_data.complete %}
<style id="netbox-utilities-topology-rack-width-styles">
{% for device in topology_rack_width_data.devices %}
.rack-device[href="{{ device.url|escapejs }}"] {
right: auto !important;
left: calc({{ device.left_percent }}% + 3px) !important;
width: calc({{ device.width_percent }}% - 6px) !important;
}
{% endfor %}
</style>
{% endif %}
{{ topology_rack_width_data|json_script:"netbox-utilities-topology-rack-width-data" }}
<script src="{% static 'netbox_utilities/topology-rack-width.js' %}?v={{ asset_version }}" defer></script>
{% endif %}
{% if reorder_rack_width_data %}
{{ reorder_rack_width_data|json_script:"netbox-utilities-reorder-rack-width-data" }}
<script src="{% static 'netbox_utilities/reorder-rack-width.js' %}?v={{ asset_version }}" defer></script>
{% endif %}
@@ -2,6 +2,13 @@
{% load perms %} {% load perms %}
{% load rack %} {% load rack %}
{% load i18n %} {% load i18n %}
{% load static %}
{% block head %}
{{ block.super }}
{{ reorder_rack_width_data|json_script:"netbox-utilities-reorder-rack-width-data" }}
<script src="{% static 'netbox_utilities/reorder-rack-width.js' %}?v={{ asset_version }}" defer></script>
{% endblock %}
{% block content %} {% block content %}
<div class="row"> <div class="row">
@@ -1,5 +1,3 @@
from unittest.mock import patch
from django.conf import settings from django.conf import settings
from django.contrib.auth.context_processors import PermWrapper from django.contrib.auth.context_processors import PermWrapper
from django.contrib.auth.models import AnonymousUser from django.contrib.auth.models import AnonymousUser
@@ -9,75 +7,17 @@ from netbox_utilities.template_content import UtilitiesGlobalContent
class OptionalRackIntegrationHeadTest(SimpleTestCase): class OptionalRackIntegrationHeadTest(SimpleTestCase):
@staticmethod def test_foreign_rack_adapters_are_not_loaded_by_the_global_head(self):
def _context(path): request = RequestFactory().get("/dcim/racks/3/reorder/")
request = RequestFactory().get(path)
request.user = AnonymousUser() request.user = AnonymousUser()
return { html = UtilitiesGlobalContent(
"request": request, {
"settings": settings, "request": request,
"csrf_token": "", "settings": settings,
"perms": PermWrapper(request.user), "csrf_token": "",
} "perms": PermWrapper(request.user),
}
).head()
def test_reorder_adapter_is_emitted_for_an_empty_enabled_payload(self): self.assertNotIn("netbox-utilities-reorder-rack-width-data", html)
data = { self.assertNotIn("netbox-utilities-topology-rack-width-data", html)
"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)
@@ -310,6 +310,8 @@ class ReorderRackFrontendTest(SimpleTestCase):
self.assertIn("{% for device in reorder_devices %}", grid) self.assertIn("{% for device in reorder_devices %}", grid)
self.assertIn('gs-w="{{ device.grid_width }}"', grid) self.assertIn('gs-w="{{ device.grid_width }}"', grid)
self.assertIn('gs-x="{{ device.grid_x }}"', 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") get_template("netbox_utilities/reorder_rack.html")
rendered_grid = render_to_string( rendered_grid = render_to_string(
@@ -403,6 +405,45 @@ class ReorderRackFrontendTest(SimpleTestCase):
self.assertEqual(template_name, "netbox_utilities/reorder_rack.html") self.assertEqual(template_name, "netbox_utilities/reorder_rack.html")
self.assertEqual(context["reorder_devices"][0]["label"], "LEO-Fritzbox") self.assertEqual(context["reorder_devices"][0]["label"], "LEO-Fritzbox")
self.assertEqual(context["reorder_devices"][0]["grid_width"], 6) 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.reorder_rack_width_enabled", return_value=True)
@patch("netbox_utilities.reorder_rack.Rack") @patch("netbox_utilities.reorder_rack.Rack")
@@ -2,10 +2,15 @@ from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from django.http import HttpResponse
from django.test import RequestFactory, SimpleTestCase from django.test import RequestFactory, SimpleTestCase
from netbox_utilities.topology_views import ( from netbox_utilities.topology_views import (
_inject_topology_widths,
_topology_data_for_visible_devices,
_visible_topology_device_urls,
get_topology_rack_width_data, get_topology_rack_width_data,
install_topology_rack_width_support,
is_topology_rack_elevation_request, is_topology_rack_elevation_request,
serialize_topology_placement, serialize_topology_placement,
topology_rack_width_enabled, topology_rack_width_enabled,
@@ -156,3 +161,108 @@ class TopologyViewsRackWidthTest(SimpleTestCase):
self.assertIn(".rack-device.netbox-utilities-partial-width", stylesheet) self.assertIn(".rack-device.netbox-utilities-partial-width", stylesheet)
self.assertIn("--netbox-utilities-rack-device-left", stylesheet) self.assertIn("--netbox-utilities-rack-device-left", stylesheet)
self.assertIn("--netbox-utilities-rack-device-width", stylesheet) self.assertIn("--netbox-utilities-rack-device-width", stylesheet)
def test_extracts_only_devices_rendered_by_the_authorized_topology_view(self):
html = """
<a class="rack-device" href="/dcim/devices/334/">Fritzbox</a>
<a class="rack-device active" href="/dcim/devices/340/">Grandstream</a>
<a href="/dcim/devices/999/">Not a rack device</a>
"""
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(
'<html><head></head><body><a class="rack-device" href="/dcim/devices/334/">A</a>'
'<a class="rack-device" href="/dcim/devices/340/">B</a></body></html>'
)
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)
+132
View File
@@ -1,15 +1,29 @@
import logging import logging
import re
from functools import wraps
from html import unescape
from importlib import import_module
from django.apps import apps from django.apps import apps
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
from django.db import OperationalError, ProgrammingError 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 netbox.plugins import get_plugin_config
from . import __version__
from .rack_width import effective_width_positions from .rack_width import effective_width_positions
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
TOPOLOGY_RACK_ELEVATION_VIEW = "plugins:netbox_topology_views:rack_elevation" 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"<a\b[^>]*\bclass=[\"'][^\"']*\brack-device\b[^\"']*[\"'][^>]*>",
re.IGNORECASE,
)
HREF_RE = re.compile(r"\bhref=[\"'](?P<url>[^\"']+)[\"']", re.IGNORECASE)
DEVICE_ID_RE = re.compile(r"(?:^|/)dcim/devices/(?P<device_id>\d+)/?(?:$|[?#])")
def is_topology_rack_elevation_request(request): def is_topology_rack_elevation_request(request):
@@ -108,3 +122,121 @@ def get_topology_rack_width_data(request):
) )
data["status"] = "native-fallback" data["status"] = "native-fallback"
return data 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'<style id="netbox-utilities-topology-rack-width-styles">{"".join(rules)}</style>'
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}<script src="{script_url}" defer></script>'
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 "</head>" 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("</head>")
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
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "netbox-utilities" 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" description = "Navigation, tenant utilities, partial-width rack devices, bulk uploads, and rack reordering for NetBox 4.6"
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"