fix: render rack integrations server-side

This commit is contained in:
2026-08-13 10:15:13 +02:00
parent 33490d40c4
commit 270d821219
9 changed files with 302 additions and 11 deletions
+6
View File
@@ -121,6 +121,12 @@ Ab Version `0.9.4` werden vorhandene Reorder-Kacheln außerdem direkt auf das
12-Spalten-Raster erweitert und nicht mehr vorsorglich neu erzeugt. Nur ein 12-Spalten-Raster erweitert und nicht mehr vorsorglich neu erzeugt. Nur ein
zweites, von NetBox' Ein-Gerät-pro-HE-Darstellung unterschlagenes Gerät wird zweites, von NetBox' Ein-Gerät-pro-HE-Darstellung unterschlagenes Gerät wird
aus den Plugin-Daten ergänzt. Das verhindert browserabhängige leere Racks. aus den Plugin-Daten ergänzt. Das verhindert browserabhängige leere Racks.
Ab Version `0.9.5` rendert das Plugin das vollständige 12-Spalten-Rack bereits
serverseitig in die Reorder-Seite. Die sichtbaren Geräte sind dadurch nicht
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.
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 -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.4" __version__ = "0.9.5"
class NetBoxUtilitiesConfig(PluginConfig): class NetBoxUtilitiesConfig(PluginConfig):
+51 -7
View File
@@ -22,6 +22,7 @@ logger = logging.getLogger(__name__)
SUPPORTED_REORDER_RACK_VERSIONS = {"1.1.4"} SUPPORTED_REORDER_RACK_VERSIONS = {"1.1.4"}
PATCH_MARKER = "_netbox_utilities_bulk_save" PATCH_MARKER = "_netbox_utilities_bulk_save"
VIEW_PATCH_MARKER = "_netbox_utilities_width_aware_view"
GRID_COLUMNS = 12 GRID_COLUMNS = 12
REORDER_RACK_VIEW = "dcim:rack_reorder" REORDER_RACK_VIEW = "dcim:rack_reorder"
@@ -394,6 +395,43 @@ def bulk_reorder_update(self, request, pk=None):
) )
def width_aware_reorder_get(self, request, pk):
"""Render every rack device server-side on the native Reorder page."""
from dcim.choices import SubdeviceRoleChoices
from django.conf import settings
from django.shortcuts import render
rack = get_object_or_404(Rack, pk=pk)
selected_view = request.GET.get("view", "images-and-labels")
images = selected_view != "labels-only"
labels = selected_view != "images-only"
non_racked = (
Device.objects.filter(rack=rack, position__isnull=True, parent_bay__isnull=True)
.exclude(device_type__subdevice_role=SubdeviceRoleChoices.ROLE_CHILD)
.select_related("device_type", "role")
)
width_data = get_reorder_rack_width_data(request)
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:
return original_get(self, request, pk)
raise Http404
return render(
request,
"netbox_utilities/reorder_rack.html",
{
"object": rack,
"images": images,
"labels": labels,
"unit_width": width_data["unit_width"],
"reorder_devices": width_data["devices"],
"non_racked": non_racked,
"basepath": settings.BASE_PATH,
},
)
def install_reorder_rack_bulk_save(): def install_reorder_rack_bulk_save():
if not apps.is_installed("netbox_reorder_rack"): if not apps.is_installed("netbox_reorder_rack"):
return False return False
@@ -408,13 +446,19 @@ def install_reorder_rack_bulk_save():
) )
return False return False
views = import_module("netbox_reorder_rack.api.views") api_views = import_module("netbox_reorder_rack.api.views")
save_viewset = views.SaveViewSet save_viewset = api_views.SaveViewSet
if getattr(save_viewset, PATCH_MARKER, False): if not getattr(save_viewset, PATCH_MARKER, False):
return True save_viewset._netbox_utilities_original_update = save_viewset.update
save_viewset.update = bulk_reorder_update
setattr(save_viewset, PATCH_MARKER, True)
page_views = import_module("netbox_reorder_rack.views")
reorder_view = page_views.ReorderView
if not getattr(reorder_view, VIEW_PATCH_MARKER, False):
reorder_view._netbox_utilities_original_get = reorder_view.get
reorder_view.get = width_aware_reorder_get
setattr(reorder_view, VIEW_PATCH_MARKER, True)
save_viewset._netbox_utilities_original_update = save_viewset.update
save_viewset.update = bulk_reorder_update
setattr(save_viewset, PATCH_MARKER, True)
logger.info("Enabled atomic multi-device saving for netbox-reorder-rack %s", plugin_version) logger.info("Enabled atomic multi-device saving for netbox-reorder-rack %s", plugin_version)
return True return True
@@ -9,6 +9,17 @@
<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 %}
{% 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" }} {{ 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> <script src="{% static 'netbox_utilities/topology-rack-width.js' %}?v={{ asset_version }}" defer></script>
{% endif %} {% endif %}
@@ -0,0 +1,51 @@
{% extends "netbox_reorder_rack/rack.html" %}
{% load perms %}
{% load rack %}
{% load i18n %}
{% block content %}
<div class="row">
<div class="d-flex justify-content-end btn-list mb-2">
<select class="btn btn-outline-secondary no-ts rack-view" id="view-selector">
<option value="images-and-labels"{% if images and labels %} selected{% endif %}>{% trans "Images and Labels" %}</option>
<option value="images-only"{% if images and not labels %} selected{% endif %}>{% trans "Images only" %}</option>
<option value="labels-only"{% if labels and not images %} selected{% endif %}>{% trans "Labels only" %}</option>
</select>
<button class="btn btn-success" id="saveButton" disabled>Save</button>
</div>
</div>
<div class="container mt-2">
<div class="row">
<div class="col-lg-4 col-md-6 col-sm-12 text-center">
<h4>Front</h4>
{% include "netbox_utilities/reorder_rack_grid.html" with face="front" grid_id=0 %}
</div>
<div class="col-lg-4 col-md-6 col-sm-12 text-center">
<h4>Back</h4>
{% include "netbox_utilities/reorder_rack_grid.html" with face="rear" grid_id=1 %}
</div>
<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"
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 }}"
gs-x="0" gs-id="{{ device.pk }}" data-item-face="none"
data-item-color="{{ device.role.color|default:'1685fc' }}"
data-item-text-color="{% if device.role.color %}{{ device.role.color|text_color }}{% else %}000000{% endif %}"
data-full-depth="{{ device.device_type.is_full_depth }}"
gs-locked="{% if not request.user|can_change:device %}true{% else %}false{% endif %}"
gs-no-move="{% if not request.user|can_change:device %}true{% else %}false{% endif %}">
<div class="grid-stack-item-content"
style="background-color: #{{ device.role.color|default:'1685fc' }}; color: #{% if device.role.color %}{{ device.role.color|text_color }}{% else %}000000{% endif %}">
{{ device.name|default:device.device_type }}
</div>
</div>
{% endfor %}
</div>
</div>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,33 @@
{% load rack %}
<div class="d-flex justify-content-center">
<div class="rack_front d-flex">
<ul class="unit_numbers">
{% for unit in object.units %}
{% 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"
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 %}
{% if device.face == face or device.full_depth %}
<div class="grid-stack-item" gs-w="{{ device.grid_width }}" gs-h="{{ device.grid_height }}"
gs-x="{{ device.grid_x }}" gs-y="{{ device.grid_y }}" gs-id="{{ device.id }}"
gs-locked="{% if device.locked %}true{% else %}false{% endif %}"
gs-no-move="{% if device.locked %}true{% else %}false{% endif %}"
data-item-color="{{ device.color }}" data-item-text-color="{{ device.text_color }}"
data-full-depth="{% if device.full_depth %}True{% else %}False{% endif %}"
data-item-face="{% if device.face == face %}{{ face }}{% else %}back{% endif %}"
data-rack-width="{{ device.width }}"
data-horizontal-position="{{ device.horizontal_position }}">
<div class="grid-stack-item-content{% if device.face != face %} device_rear{% endif %}"
style="{% if device.face == face %}background-color: #{{ device.color }}; color: #{{ device.text_color }}{% else %}color: #000000{% endif %}">
{{ device.label }}
</div>
</div>
{% endif %}
{% endfor %}
</div>
<ul class="indexes"></ul>
</div>
</div>
@@ -54,3 +54,30 @@ class OptionalRackIntegrationHeadTest(SimpleTestCase):
self.assertIn('id="netbox-utilities-topology-rack-width-data"', html) self.assertIn('id="netbox-utilities-topology-rack-width-data"', html)
self.assertIn("netbox_utilities/topology-rack-width.js", html) self.assertIn("netbox_utilities/topology-rack-width.js", html)
self.assertIn('"status": "ready"', 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)
+121 -2
View File
@@ -3,7 +3,9 @@ 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.template.loader import get_template, render_to_string
from django.test import RequestFactory, SimpleTestCase from django.test import RequestFactory, SimpleTestCase
from django.urls import resolve, reverse
from rest_framework.exceptions import PermissionDenied from rest_framework.exceptions import PermissionDenied
from netbox_utilities.reorder_rack import ( from netbox_utilities.reorder_rack import (
@@ -16,6 +18,7 @@ from netbox_utilities.reorder_rack import (
install_reorder_rack_bulk_save, install_reorder_rack_bulk_save,
parse_device_placements, parse_device_placements,
reorder_grid_y, reorder_grid_y,
width_aware_reorder_get,
) )
@@ -297,6 +300,110 @@ class ReorderRackFrontendTest(SimpleTestCase):
self.assertNotIn("partialDeviceIds", script) self.assertNotIn("partialDeviceIds", script)
self.assertNotIn("JSON.stringify", script) self.assertNotIn("JSON.stringify", script)
def test_server_template_uses_complete_twelve_column_device_payload(self):
templates = Path(__file__).parents[1] / "templates" / "netbox_utilities"
page = (templates / "reorder_rack.html").read_text(encoding="utf-8")
grid = (templates / "reorder_rack_grid.html").read_text(encoding="utf-8")
self.assertIn('include "netbox_utilities/reorder_rack_grid.html"', page)
self.assertIn('gs-column="12"', grid)
self.assertIn("{% for device in reorder_devices %}", grid)
self.assertIn('gs-w="{{ device.grid_width }}"', grid)
self.assertIn('gs-x="{{ device.grid_x }}"', grid)
get_template("netbox_utilities/reorder_rack.html")
rendered_grid = render_to_string(
"netbox_utilities/reorder_rack_grid.html",
{
"object": SimpleNamespace(units=[1], u_height=42),
"face": "front",
"grid_id": 0,
"reorder_devices": [
{
"id": 10,
"label": "LEO-Fritzbox",
"face": "front",
"full_depth": False,
"grid_x": 0,
"grid_y": 62,
"grid_width": 6,
"grid_height": 2,
"width": 2,
"horizontal_position": 1,
"color": "f0a000",
"text_color": "000000",
"locked": False,
}
],
},
)
self.assertIn('gs-column="12"', rendered_grid)
self.assertIn('gs-id="10"', rendered_grid)
self.assertIn("LEO-Fritzbox", rendered_grid)
def test_resolved_reorder_page_uses_server_side_width_view(self):
view_class = resolve(reverse("dcim:rack_reorder", kwargs={"pk": 3})).func.view_class
self.assertIs(view_class.get, width_aware_reorder_get)
self.assertEqual(view_class.get.__module__, "netbox_utilities.reorder_rack")
@patch("netbox_utilities.reorder_rack.get_reorder_rack_width_data")
@patch("netbox_utilities.reorder_rack.get_object_or_404")
@patch("netbox_utilities.reorder_rack.Device")
@patch("django.shortcuts.render")
def test_server_side_reorder_view_renders_payload_devices(
self,
render_mock,
device_model,
get_object,
get_width_data,
):
from dcim.models import Rack, Site
rack = Rack(
pk=3,
name="Rack02 - Rechts",
site=Site(pk=1, name="AWO-MWT Leonardo Schule", slug="leonardo"),
u_height=42,
)
get_object.return_value = rack
device_model.objects.filter.return_value.exclude.return_value.select_related.return_value = []
get_width_data.return_value = {
"complete": True,
"unit_width": 220,
"devices": [
{
"id": 10,
"label": "LEO-Fritzbox",
"face": "front",
"full_depth": False,
"grid_x": 0,
"grid_y": 62,
"grid_width": 6,
"grid_height": 2,
"width": 2,
"horizontal_position": 1,
"color": "f0a000",
"text_color": "000000",
"locked": False,
}
],
}
request = RequestFactory().get("/dcim/racks/3/reorder/")
request.user = MagicMock()
request.user.is_authenticated = True
expected_response = SimpleNamespace(status_code=200)
render_mock.return_value = expected_response
response = width_aware_reorder_get(SimpleNamespace(), request, 3)
self.assertIs(response, expected_response)
template_name = render_mock.call_args.args[1]
context = render_mock.call_args.args[2]
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)
@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")
def test_malformed_partial_placement_fails_open(self, rack_model, _enabled): def test_malformed_partial_placement_fails_open(self, rack_model, _enabled):
@@ -433,22 +540,34 @@ class ReorderRackPatchTest(SimpleTestCase):
def test_patches_supported_plugin_only_once(self): def test_patches_supported_plugin_only_once(self):
original_update = object() original_update = object()
def original_get(self, request, pk):
return self, request, pk
class SaveViewSet: class SaveViewSet:
update = original_update update = original_update
views = SimpleNamespace(SaveViewSet=SaveViewSet) class ReorderView:
get = original_get
api_views = SimpleNamespace(SaveViewSet=SaveViewSet)
page_views = SimpleNamespace(ReorderView=ReorderView)
plugin_config = SimpleNamespace(version="1.1.4") plugin_config = SimpleNamespace(version="1.1.4")
with ( with (
patch("netbox_utilities.reorder_rack.apps.is_installed", return_value=True), patch("netbox_utilities.reorder_rack.apps.is_installed", return_value=True),
patch("netbox_utilities.reorder_rack.apps.get_app_config", return_value=plugin_config), patch("netbox_utilities.reorder_rack.apps.get_app_config", return_value=plugin_config),
patch("netbox_utilities.reorder_rack.import_module", return_value=views), patch(
"netbox_utilities.reorder_rack.import_module",
side_effect=lambda name: api_views if name.endswith("api.views") else page_views,
),
): ):
self.assertTrue(install_reorder_rack_bulk_save()) self.assertTrue(install_reorder_rack_bulk_save())
self.assertTrue(install_reorder_rack_bulk_save()) self.assertTrue(install_reorder_rack_bulk_save())
self.assertIs(SaveViewSet.update, bulk_reorder_update) self.assertIs(SaveViewSet.update, bulk_reorder_update)
self.assertIs(SaveViewSet._netbox_utilities_original_update, original_update) self.assertIs(SaveViewSet._netbox_utilities_original_update, original_update)
self.assertIs(ReorderView.get, width_aware_reorder_get)
self.assertIs(ReorderView._netbox_utilities_original_get, original_get)
def test_skips_unsupported_plugin_version(self): def test_skips_unsupported_plugin_version(self):
with ( with (
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "netbox-utilities" name = "netbox-utilities"
version = "0.9.4" version = "0.9.5"
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"