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
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.
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
keine Plugin-Platzierungszeile besitzen, werden in Rack-SVG, Reorder und der
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
__version__ = "0.9.4"
__version__ = "0.9.5"
class NetBoxUtilitiesConfig(PluginConfig):
+49 -5
View File
@@ -22,6 +22,7 @@ logger = logging.getLogger(__name__)
SUPPORTED_REORDER_RACK_VERSIONS = {"1.1.4"}
PATCH_MARKER = "_netbox_utilities_bulk_save"
VIEW_PATCH_MARKER = "_netbox_utilities_width_aware_view"
GRID_COLUMNS = 12
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():
if not apps.is_installed("netbox_reorder_rack"):
return False
@@ -408,13 +446,19 @@ def install_reorder_rack_bulk_save():
)
return False
views = import_module("netbox_reorder_rack.api.views")
save_viewset = views.SaveViewSet
if getattr(save_viewset, PATCH_MARKER, False):
return True
api_views = import_module("netbox_reorder_rack.api.views")
save_viewset = api_views.SaveViewSet
if not getattr(save_viewset, PATCH_MARKER, False):
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)
logger.info("Enabled atomic multi-device saving for netbox-reorder-rack %s", plugin_version)
return True
@@ -9,6 +9,17 @@
<script src="{% static 'netbox_utilities/navigation.js' %}?v={{ asset_version }}" defer></script>
{% 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 %}
@@ -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("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)
+121 -2
View File
@@ -3,7 +3,9 @@ from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from django.template.loader import get_template, render_to_string
from django.test import RequestFactory, SimpleTestCase
from django.urls import resolve, reverse
from rest_framework.exceptions import PermissionDenied
from netbox_utilities.reorder_rack import (
@@ -16,6 +18,7 @@ from netbox_utilities.reorder_rack import (
install_reorder_rack_bulk_save,
parse_device_placements,
reorder_grid_y,
width_aware_reorder_get,
)
@@ -297,6 +300,110 @@ class ReorderRackFrontendTest(SimpleTestCase):
self.assertNotIn("partialDeviceIds", 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.Rack")
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):
original_update = object()
def original_get(self, request, pk):
return self, request, pk
class SaveViewSet:
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")
with (
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.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.assertIs(SaveViewSet.update, bulk_reorder_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):
with (
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
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"
readme = "README.md"
requires-python = ">=3.12"