fix: bind rack integrations to rendered devices
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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__,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user