fix: render rack integrations server-side
This commit is contained in:
@@ -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,6 +1,6 @@
|
||||
from netbox.plugins import PluginConfig, get_plugin_config
|
||||
|
||||
__version__ = "0.9.4"
|
||||
__version__ = "0.9.5"
|
||||
|
||||
|
||||
class NetBoxUtilitiesConfig(PluginConfig):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user