diff --git a/README.md b/README.md
index 952032d..bfa7057 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/netbox_utilities/__init__.py b/netbox_utilities/__init__.py
index f2676f9..a91de06 100644
--- a/netbox_utilities/__init__.py
+++ b/netbox_utilities/__init__.py
@@ -1,6 +1,6 @@
from netbox.plugins import PluginConfig, get_plugin_config
-__version__ = "0.9.4"
+__version__ = "0.9.5"
class NetBoxUtilitiesConfig(PluginConfig):
diff --git a/netbox_utilities/reorder_rack.py b/netbox_utilities/reorder_rack.py
index a12fc32..1b676a7 100644
--- a/netbox_utilities/reorder_rack.py
+++ b/netbox_utilities/reorder_rack.py
@@ -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)
- 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)
return True
diff --git a/netbox_utilities/templates/netbox_utilities/head.html b/netbox_utilities/templates/netbox_utilities/head.html
index eeb5728..8e20047 100644
--- a/netbox_utilities/templates/netbox_utilities/head.html
+++ b/netbox_utilities/templates/netbox_utilities/head.html
@@ -9,6 +9,17 @@
{% endif %}
{% if topology_rack_width_data %}
+ {% if topology_rack_width_data.complete %}
+
+ {% endif %}
{{ topology_rack_width_data|json_script:"netbox-utilities-topology-rack-width-data" }}
{% endif %}
diff --git a/netbox_utilities/templates/netbox_utilities/reorder_rack.html b/netbox_utilities/templates/netbox_utilities/reorder_rack.html
new file mode 100644
index 0000000..a3eb818
--- /dev/null
+++ b/netbox_utilities/templates/netbox_utilities/reorder_rack.html
@@ -0,0 +1,51 @@
+{% extends "netbox_reorder_rack/rack.html" %}
+{% load perms %}
+{% load rack %}
+{% load i18n %}
+
+{% block content %}
+
+
+
+
+
+
+
+
+
+
Front
+ {% include "netbox_utilities/reorder_rack_grid.html" with face="front" grid_id=0 %}
+
+
+
Back
+ {% include "netbox_utilities/reorder_rack_grid.html" with face="rear" grid_id=1 %}
+
+
+
Non-Racked Devices
+
+
+ {% for device in non_racked %}
+
+
+ {{ device.name|default:device.device_type }}
+
+
+ {% endfor %}
+
+
+
+
+
+{% endblock %}
diff --git a/netbox_utilities/templates/netbox_utilities/reorder_rack_grid.html b/netbox_utilities/templates/netbox_utilities/reorder_rack_grid.html
new file mode 100644
index 0000000..baa60f4
--- /dev/null
+++ b/netbox_utilities/templates/netbox_utilities/reorder_rack_grid.html
@@ -0,0 +1,33 @@
+{% load rack %}
+
+
+
+ {% for unit in object.units %}
+ {% if unit|rack_unit %}- {{ unit|rack_unit_to_int }}
{% else %}{% endif %}
+ {% endfor %}
+
+
+ {% for device in reorder_devices %}
+ {% if device.face == face or device.full_depth %}
+
+
+ {{ device.label }}
+
+
+ {% endif %}
+ {% endfor %}
+
+
+
+
diff --git a/netbox_utilities/tests/test_optional_integration_pages.py b/netbox_utilities/tests/test_optional_integration_pages.py
index 49bb54b..8da4714 100644
--- a/netbox_utilities/tests/test_optional_integration_pages.py
+++ b/netbox_utilities/tests/test_optional_integration_pages.py
@@ -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)
diff --git a/netbox_utilities/tests/test_reorder_rack.py b/netbox_utilities/tests/test_reorder_rack.py
index d2a2400..98add6c 100644
--- a/netbox_utilities/tests/test_reorder_rack.py
+++ b/netbox_utilities/tests/test_reorder_rack.py
@@ -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 (
diff --git a/pyproject.toml b/pyproject.toml
index d522be9..d0740e9 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -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"