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 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"