fix: restore visible rack integration widgets
This commit is contained in:
@@ -132,6 +132,12 @@ 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.
|
||||
Ab Version `0.9.7` enthält Reorder eigene 12-Spalten-CSS-Regeln. Diese beheben
|
||||
die Inkompatibilität zwischen GridStack 10 aus Reorder Rack `1.1.4` und dem
|
||||
GridStack-11-Stylesheet von NetBox 4.6.7, durch die Gerätekacheln mit einer
|
||||
berechneten Breite von `0px` unsichtbar waren. Topology-Breiten werden außerdem
|
||||
nach dem vollständigen Rendern zentral auf die HTTP-Antwort angewendet und
|
||||
zusätzlich direkt in die vorhandenen Geräte-Styles geschrieben.
|
||||
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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from netbox.plugins import PluginConfig, get_plugin_config
|
||||
|
||||
__version__ = "0.9.6"
|
||||
__version__ = "0.9.7"
|
||||
|
||||
|
||||
class NetBoxUtilitiesConfig(PluginConfig):
|
||||
@@ -38,10 +38,6 @@ 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
|
||||
|
||||
@@ -22,7 +22,10 @@ class GlobalTenantFilterMiddleware:
|
||||
try:
|
||||
if scope is not None and request.method in {"GET", "HEAD"}:
|
||||
self._inject_filter_parameter(request, scope)
|
||||
return self.get_response(request)
|
||||
response = self.get_response(request)
|
||||
from .topology_views import apply_topology_rack_widths
|
||||
|
||||
return apply_topology_rack_widths(request, response)
|
||||
finally:
|
||||
active_tenant_scope.reset(token)
|
||||
|
||||
|
||||
@@ -36,6 +36,41 @@
|
||||
width: calc(var(--netbox-utilities-rack-device-width) - 6px) !important;
|
||||
}
|
||||
|
||||
/*
|
||||
* netbox-reorder-rack 1.1.4 bundles GridStack 10, while NetBox 4.6.7
|
||||
* provides GridStack 11's base stylesheet. GridStack 11 calculates item
|
||||
* widths through a CSS variable which the older runtime never sets. Without
|
||||
* these explicit rules every 12-column widget therefore has width: 0px.
|
||||
*/
|
||||
.netbox-utilities-reorder-grid[gs-column="12"] > .grid-stack-item {
|
||||
width: calc(100% / 12) !important;
|
||||
}
|
||||
|
||||
.netbox-utilities-reorder-grid[gs-column="12"] > .grid-stack-item[gs-w="2"] { width: calc(200% / 12) !important; }
|
||||
.netbox-utilities-reorder-grid[gs-column="12"] > .grid-stack-item[gs-w="3"] { width: 25% !important; }
|
||||
.netbox-utilities-reorder-grid[gs-column="12"] > .grid-stack-item[gs-w="4"] { width: calc(400% / 12) !important; }
|
||||
.netbox-utilities-reorder-grid[gs-column="12"] > .grid-stack-item[gs-w="5"] { width: calc(500% / 12) !important; }
|
||||
.netbox-utilities-reorder-grid[gs-column="12"] > .grid-stack-item[gs-w="6"] { width: 50% !important; }
|
||||
.netbox-utilities-reorder-grid[gs-column="12"] > .grid-stack-item[gs-w="7"] { width: calc(700% / 12) !important; }
|
||||
.netbox-utilities-reorder-grid[gs-column="12"] > .grid-stack-item[gs-w="8"] { width: calc(800% / 12) !important; }
|
||||
.netbox-utilities-reorder-grid[gs-column="12"] > .grid-stack-item[gs-w="9"] { width: 75% !important; }
|
||||
.netbox-utilities-reorder-grid[gs-column="12"] > .grid-stack-item[gs-w="10"] { width: calc(1000% / 12) !important; }
|
||||
.netbox-utilities-reorder-grid[gs-column="12"] > .grid-stack-item[gs-w="11"] { width: calc(1100% / 12) !important; }
|
||||
.netbox-utilities-reorder-grid[gs-column="12"] > .grid-stack-item[gs-w="12"] { width: 100% !important; }
|
||||
|
||||
.netbox-utilities-reorder-grid[gs-column="12"] > .grid-stack-item[gs-x="0"] { left: 0 !important; }
|
||||
.netbox-utilities-reorder-grid[gs-column="12"] > .grid-stack-item[gs-x="1"] { left: calc(100% / 12) !important; }
|
||||
.netbox-utilities-reorder-grid[gs-column="12"] > .grid-stack-item[gs-x="2"] { left: calc(200% / 12) !important; }
|
||||
.netbox-utilities-reorder-grid[gs-column="12"] > .grid-stack-item[gs-x="3"] { left: 25% !important; }
|
||||
.netbox-utilities-reorder-grid[gs-column="12"] > .grid-stack-item[gs-x="4"] { left: calc(400% / 12) !important; }
|
||||
.netbox-utilities-reorder-grid[gs-column="12"] > .grid-stack-item[gs-x="5"] { left: calc(500% / 12) !important; }
|
||||
.netbox-utilities-reorder-grid[gs-column="12"] > .grid-stack-item[gs-x="6"] { left: 50% !important; }
|
||||
.netbox-utilities-reorder-grid[gs-column="12"] > .grid-stack-item[gs-x="7"] { left: calc(700% / 12) !important; }
|
||||
.netbox-utilities-reorder-grid[gs-column="12"] > .grid-stack-item[gs-x="8"] { left: calc(800% / 12) !important; }
|
||||
.netbox-utilities-reorder-grid[gs-column="12"] > .grid-stack-item[gs-x="9"] { left: 75% !important; }
|
||||
.netbox-utilities-reorder-grid[gs-column="12"] > .grid-stack-item[gs-x="10"] { left: calc(1000% / 12) !important; }
|
||||
.netbox-utilities-reorder-grid[gs-column="12"] > .grid-stack-item[gs-x="11"] { left: calc(1100% / 12) !important; }
|
||||
|
||||
.netbox-utilities-sidebar-resizer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
<div class="col-lg-4 col-md-6 col-sm-12 text-center">
|
||||
<h4>Non-Racked Devices</h4>
|
||||
<div class="d-flex justify-content-center">
|
||||
<div class="grid-stack rack" id="grid-other" gs-column="12" gs-max-row="150"
|
||||
<div class="grid-stack rack netbox-utilities-reorder-grid" id="grid-other" gs-column="12" gs-max-row="150"
|
||||
gs-min-row="{{ object.u_height|mul:2 }}" data-grid-id="2">
|
||||
{% for device in non_racked %}
|
||||
<div class="grid-stack-item" gs-w="12" gs-h="{{ device.device_type.u_height|mul:2 }}"
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
{% if unit|rack_unit %}<li>{{ unit|rack_unit_to_int }}</li>{% else %}<li></li>{% endif %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<div class="grid-stack rack" id="grid-{{ face }}" gs-column="12"
|
||||
<div class="grid-stack rack netbox-utilities-reorder-grid" id="grid-{{ face }}" gs-column="12"
|
||||
gs-max-row="{{ object.u_height|mul:2 }}" gs-min-row="{{ object.u_height|mul:2 }}"
|
||||
data-grid-id="{{ grid_id }}">
|
||||
{% for device in reorder_devices %}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.http import HttpResponse
|
||||
from django.test import RequestFactory, SimpleTestCase
|
||||
|
||||
from netbox_utilities.middleware import GlobalTenantFilterMiddleware
|
||||
@@ -28,6 +29,21 @@ class GlobalTenantFilterMiddlewareTest(SimpleTestCase):
|
||||
def setUp(self):
|
||||
self.factory = RequestFactory()
|
||||
|
||||
@patch("netbox_utilities.topology_views.apply_topology_rack_widths")
|
||||
@patch("netbox_utilities.middleware.GlobalTenantFilterMiddleware._get_selected_scope", return_value=None)
|
||||
def test_processes_topology_widths_after_the_view_response(self, _scope, apply_widths):
|
||||
native_response = HttpResponse("native topology")
|
||||
final_response = HttpResponse("width-aware topology")
|
||||
apply_widths.return_value = final_response
|
||||
middleware = GlobalTenantFilterMiddleware(lambda _request: native_response)
|
||||
request = self.factory.get("/plugins/netbox_topology_views/rack-elevation/?rack_id=3")
|
||||
request.session = {}
|
||||
|
||||
result = middleware(request)
|
||||
|
||||
self.assertIs(result, final_response)
|
||||
apply_widths.assert_called_once_with(request, native_response)
|
||||
|
||||
@patch("netbox_utilities.middleware.resolve")
|
||||
def test_injects_and_overrides_tenant_id(self, mocked_resolve):
|
||||
mocked_resolve.return_value = resolver_match(TenantFilterSet)
|
||||
|
||||
@@ -310,6 +310,7 @@ 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("netbox-utilities-reorder-grid", grid)
|
||||
self.assertIn('json_script:"netbox-utilities-reorder-rack-width-data"', page)
|
||||
self.assertIn("netbox_utilities/reorder-rack-width.js", page)
|
||||
|
||||
@@ -343,6 +344,14 @@ class ReorderRackFrontendTest(SimpleTestCase):
|
||||
self.assertIn('gs-id="10"', rendered_grid)
|
||||
self.assertIn("LEO-Fritzbox", rendered_grid)
|
||||
|
||||
stylesheet = (Path(__file__).parents[1] / "static" / "netbox_utilities" / "netbox_utilities.css").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
self.assertIn('.netbox-utilities-reorder-grid[gs-column="12"]', stylesheet)
|
||||
self.assertIn('.grid-stack-item[gs-w="6"] { width: 50% !important; }', stylesheet)
|
||||
self.assertIn('.grid-stack-item[gs-w="12"] { width: 100% !important; }', stylesheet)
|
||||
self.assertIn('.grid-stack-item[gs-x="6"] { left: 50% !important; }', stylesheet)
|
||||
|
||||
def test_resolved_reorder_page_uses_server_side_width_view(self):
|
||||
view_class = resolve(reverse("dcim:rack_reorder", kwargs={"pk": 3})).func.view_class
|
||||
|
||||
@@ -405,7 +414,7 @@ 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.assertEqual(context["asset_version"], "0.9.7")
|
||||
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)
|
||||
|
||||
@@ -6,11 +6,12 @@ from django.http import HttpResponse
|
||||
from django.test import RequestFactory, SimpleTestCase
|
||||
|
||||
from netbox_utilities.topology_views import (
|
||||
_apply_topology_widths_to_device_tags,
|
||||
_inject_topology_widths,
|
||||
_topology_data_for_visible_devices,
|
||||
_visible_topology_device_urls,
|
||||
apply_topology_rack_widths,
|
||||
get_topology_rack_width_data,
|
||||
install_topology_rack_width_support,
|
||||
is_topology_rack_elevation_request,
|
||||
serialize_topology_placement,
|
||||
topology_rack_width_enabled,
|
||||
@@ -245,24 +246,40 @@ class TopologyViewsRackWidthTest(SimpleTestCase):
|
||||
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)
|
||||
self.assertIn("netbox_utilities/topology-rack-width.js?v=0.9.7", html)
|
||||
self.assertIn("left:calc(0% + 3px)!important", html)
|
||||
self.assertIn("left:calc(50% + 3px)!important", html)
|
||||
self.assertIn("width:calc(50% - 6px)!important", html)
|
||||
|
||||
def test_patches_the_optional_topology_view_only_once(self):
|
||||
def original_get(self, request):
|
||||
return self, request
|
||||
def test_preserves_existing_topology_style_while_applying_width(self):
|
||||
data = {
|
||||
"devices": [
|
||||
{
|
||||
"url": "/dcim/devices/334/",
|
||||
"left_percent": 0,
|
||||
"width_percent": 50,
|
||||
}
|
||||
]
|
||||
}
|
||||
html = (
|
||||
'<a class="rack-device" href="/dcim/devices/334/" '
|
||||
'style="bottom: 10%; height: 2%; border-color: #f00">Fritzbox</a>'
|
||||
)
|
||||
|
||||
class RackElevationView:
|
||||
get = original_get
|
||||
result = _apply_topology_widths_to_device_tags(html, data)
|
||||
|
||||
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.assertIn("bottom: 10%", result)
|
||||
self.assertIn("height: 2%", result)
|
||||
self.assertIn("left:calc(0% + 3px)!important", result)
|
||||
self.assertIn("width:calc(50% - 6px)!important", result)
|
||||
|
||||
self.assertIs(RackElevationView.get, patched_get)
|
||||
self.assertIs(RackElevationView._netbox_utilities_original_get, original_get)
|
||||
@patch("netbox_utilities.topology_views._inject_topology_widths")
|
||||
@patch("netbox_utilities.topology_views.topology_rack_width_enabled", return_value=True)
|
||||
def test_response_processor_runs_after_the_topology_view(self, _enabled, inject):
|
||||
response = HttpResponse("<html></html>")
|
||||
inject.return_value = response
|
||||
|
||||
result = apply_topology_rack_widths(self.request, response)
|
||||
|
||||
self.assertIs(result, response)
|
||||
inject.assert_called_once_with(response)
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
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
|
||||
@@ -17,12 +15,12 @@ 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"<a\b[^>]*\bclass=[\"'][^\"']*\brack-device\b[^\"']*[\"'][^>]*>",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
HREF_RE = re.compile(r"\bhref=[\"'](?P<url>[^\"']+)[\"']", re.IGNORECASE)
|
||||
STYLE_RE = re.compile(r"\bstyle=(?P<quote>[\"'])(?P<style>.*?)(?P=quote)", re.IGNORECASE)
|
||||
DEVICE_ID_RE = re.compile(r"(?:^|/)dcim/devices/(?P<device_id>\d+)/?(?:$|[?#])")
|
||||
|
||||
|
||||
@@ -179,6 +177,34 @@ def _topology_width_head(data):
|
||||
return f'{stylesheet}{payload}<script src="{script_url}" defer></script>'
|
||||
|
||||
|
||||
def _apply_topology_widths_to_device_tags(html, data):
|
||||
descriptors = {device["url"]: device for device in data["devices"]}
|
||||
|
||||
def replace_tag(match):
|
||||
tag = match.group(0)
|
||||
href_match = HREF_RE.search(tag)
|
||||
if href_match is None:
|
||||
return tag
|
||||
descriptor = descriptors.get(unescape(href_match.group("url")))
|
||||
if descriptor is None:
|
||||
return tag
|
||||
|
||||
geometry = (
|
||||
"right:auto!important;"
|
||||
f"left:calc({descriptor['left_percent']}% + 3px)!important;"
|
||||
f"width:calc({descriptor['width_percent']}% - 6px)!important"
|
||||
)
|
||||
style_match = STYLE_RE.search(tag)
|
||||
if style_match is not None:
|
||||
quote = style_match.group("quote")
|
||||
existing = style_match.group("style").rstrip().rstrip(";")
|
||||
replacement = f"style={quote}{existing};{geometry}{quote}"
|
||||
return f"{tag[: style_match.start()]}{replacement}{tag[style_match.end() :]}"
|
||||
return f'{tag[:-1]} style="{geometry}">'
|
||||
|
||||
return RACK_DEVICE_TAG_RE.sub(replace_tag, html)
|
||||
|
||||
|
||||
def _inject_topology_widths(response):
|
||||
if (
|
||||
getattr(response, "streaming", False)
|
||||
@@ -192,6 +218,7 @@ def _inject_topology_widths(response):
|
||||
return response
|
||||
visible_urls = _visible_topology_device_urls(html)
|
||||
data = _topology_data_for_visible_devices(visible_urls)
|
||||
html = _apply_topology_widths_to_device_tags(html, data)
|
||||
insertion = _topology_width_head(data)
|
||||
head_end = html.lower().index("</head>")
|
||||
html = f"{html[:head_end]}{insertion}{html[head_end:]}"
|
||||
@@ -202,41 +229,16 @@ def _inject_topology_widths(response):
|
||||
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)
|
||||
def apply_topology_rack_widths(request, response):
|
||||
"""Apply widths after the optional view has rendered its authorized devices."""
|
||||
if not topology_rack_width_enabled(request):
|
||||
return response
|
||||
try:
|
||||
return _inject_topology_widths(response)
|
||||
except (AttributeError, ObjectDoesNotExist, OperationalError, ProgrammingError, TypeError, ValueError):
|
||||
except Exception:
|
||||
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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "netbox-utilities"
|
||||
version = "0.9.6"
|
||||
version = "0.9.7"
|
||||
description = "Navigation, tenant utilities, partial-width rack devices, bulk uploads, and rack reordering for NetBox 4.6"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
Reference in New Issue
Block a user