fix: restore populated rack and reorder views

This commit is contained in:
2026-08-13 09:37:59 +02:00
parent bc2024ac13
commit a5f8cb62c7
12 changed files with 379 additions and 67 deletions
+38 -5
View File
@@ -105,7 +105,7 @@ sudo systemctl restart netbox netbox-rq
Nach Änderungen an JavaScript oder CSS kann ein Hard-Reload des Browsers mit
`Strg+F5` erforderlich sein.
Ab Version `0.9.2` werden Rack-SVG und Reorder direkt aus allen Geräten des
Ab Version `0.9.3` werden Rack-SVG und Reorder direkt aus allen Geräten des
Racks aufgebaut. Dadurch können mehrere Geräte derselben HE nicht mehr durch
NetBox' native Ein-Gerät-pro-HE-Darstellung verloren gehen. Die Adapter werden
auch dann eingebunden, wenn keine Teilbreitengeräte gefunden wurden. Nach
@@ -113,6 +113,16 @@ diesem Update ist `collectstatic` deshalb zwingend erforderlich;
die Dateien `topology-rack-width.js` und `reorder-rack-width.js` müssen unter
`/opt/netbox/netbox/static/netbox_utilities/` vorhanden sein.
Reorder ersetzt seine nativen Gerätekacheln erst, nachdem ein vollständiger
und zur JavaScript-Version passender Datensatz geprüft wurde. Bei einer
gemischten Installation aus altem Python-Code und neuen statischen Dateien
bleibt deshalb die native Ansicht erhalten, statt ein leeres Rack zu zeigen.
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
dargestellt. Explizit gespeicherte Rackbreiten und Breitenpositionen haben
stets Vorrang vor dieser Anzeige-Ableitung.
Die Installation lässt sich anschließend mit diesen Befehlen kontrollieren:
```bash
@@ -121,6 +131,8 @@ Die Installation lässt sich anschließend mit diesen Befehlen kontrollieren:
test -f /opt/netbox/netbox/static/netbox_utilities/reorder-rack-width.js
test -f /opt/netbox/netbox/static/netbox_utilities/topology-rack-width.js
grep -q "schema_version !== 3" \
/opt/netbox/netbox/static/netbox_utilities/reorder-rack-width.js
cd /opt/netbox/netbox
/opt/netbox/venv/bin/python manage.py shell -c \
@@ -131,6 +143,23 @@ Die letzten beiden Routen müssen auflösbar sein. Die Topology-Rack-Route ist
im getesteten MrBlake-Develop-Stand `86a8daf45ef380d57ac73b461dbc47a16bb831ba`
enthalten, aber nicht im gleich bezeichneten öffentlichen Tag `v4.5.1`.
Für Rack `2` kann zusätzlich geprüft werden, welche gespeicherten Breiten das
Plugin findet. Zwei Geräte derselben HE dürfen dabei unterschiedliche oder
noch leere Plugin-Werte besitzen; leere Werte werden ab `0.9.3` für die
Darstellung abgeleitet:
```bash
cd /opt/netbox/netbox
/opt/netbox/venv/bin/python manage.py shell -c "
from dcim.models import Device
print(list(Device.objects.filter(rack_id=2).order_by('position', 'id').values(
'id', 'name', 'position', 'face',
'netbox_utilities_rack_placement__width',
'netbox_utilities_rack_placement__horizontal_position',
)))
"
```
## Aktualisierung
Bei einer Installation aus dem `main`-Branch wird das Paket erneut aus Gitea
@@ -183,10 +212,14 @@ unterstützt.
Nach einer Änderung der Rackbreite wird die **Breitenposition** unmittelbar
aktualisiert; ein Zwischenspeichern oder Neuladen ist nicht erforderlich.
Ohne Breitenangabe belegt ein Gerät wie bisher die volle Rackbreite. Das gilt
automatisch für sämtliche vorhandenen Geräte; es findet keine Änderung oder
Migration bestehender Platzierungen statt. Teilbreite und Position werden auf
der Geräteseite angezeigt und in der Rackgrafik nebeneinander dargestellt.
Ohne Breitenangabe belegt ein Gerät wie bisher die volle Rackbreite. Ausnahme
ist eine bereits vorhandene gemeinsame Belegung derselben HE und Rackseite:
Fehlen dort Plugin-Platzierungen, teilt die Darstellung den verfügbaren Platz
gleichmäßig auf die zwei bis vier vorhandenen Geräte auf. Diese Ableitung
ändert keine Datenbankwerte und dient nur dazu, bereits gemeinsam eingepflegte
Geräte wieder sichtbar zu machen. Es findet keine Migration bestehender
Platzierungen statt. Explizit gewählte Teilbreite und Position werden auf der
Geräteseite angezeigt und in der Rackgrafik nebeneinander dargestellt.
NetBox besitzt standardmäßig eine Datenbank-Eindeutigkeit für Rack, HE und
Rackseite. Die Plugin-Migration `0007` entfernt ausschließlich diese
+1 -1
View File
@@ -1,6 +1,6 @@
from netbox.plugins import PluginConfig, get_plugin_config
__version__ = "0.9.2"
__version__ = "0.9.3"
class NetBoxUtilitiesConfig(PluginConfig):
+68 -10
View File
@@ -1,5 +1,6 @@
import decimal
import logging
from collections import defaultdict
from contextvars import ContextVar
from copy import deepcopy
from fractions import Fraction
@@ -114,6 +115,65 @@ def get_width_position(device, placements=None):
return _stored_width_position(device)
def effective_width_positions(devices, placements=None):
"""Return stored rack widths and infer missing rows for devices sharing one slot."""
devices = list(devices)
groups = defaultdict(list)
result = {}
for device in devices:
key = (
device.rack_id,
device.face,
decimal.Decimal(str(device.position)),
decimal.Decimal(str(device.device_type.u_height)),
)
groups[key].append(device)
for group in groups.values():
stored = {}
missing = []
for device in group:
if placements is not None:
placement = placements.get(device.pk)
else:
try:
placement = device.netbox_utilities_rack_placement
except ObjectDoesNotExist:
placement = None
if placement is None:
missing.append(device)
else:
stored[device.pk] = normalize_width_position(placement.width, placement.horizontal_position)
candidate_width = None
stored_widths = {width for width, _position in stored.values()}
if len(stored_widths) == 1:
stored_width = next(iter(stored_widths))
if len(group) <= stored_width:
candidate_width = stored_width
elif not stored and 2 <= len(group) <= 4:
candidate_width = len(group)
if candidate_width is not None:
used_positions = {position for width, position in stored.values() if width == candidate_width}
free_positions = iter(
position for position in range(1, candidate_width + 1) if position not in used_positions
)
for device in sorted(missing, key=lambda item: item.pk):
position = next(free_positions, None)
if position is None:
break
result[device.pk] = (candidate_width, position, "inferred")
for device_id, (width, position) in stored.items():
result[device_id] = (width, position, "stored")
for device in missing:
result.setdefault(device.pk, (FULL_WIDTH, 1, "default"))
return result
def stage_width_position(device, width, horizontal_position):
width, horizontal_position = normalize_width_position(width, horizontal_position)
device._netbox_utilities_rack_width = width
@@ -432,7 +492,7 @@ def _install_device_form():
def _install_rack_svg():
from dcim.svg.racks import RackElevationSVG
if getattr(RackElevationSVG, "_netbox_utilities_rack_width_version", None) == 3:
if getattr(RackElevationSVG, "_netbox_utilities_rack_width_version", None) == 4:
return
current_draw_face = RackElevationSVG.draw_face
current_globals = getattr(current_draw_face, "__globals__", {})
@@ -448,16 +508,14 @@ def _install_rack_svg():
# NetBox's get_rack_units() stores only one device per rack unit and
# therefore cannot represent two devices mounted beside each other.
# Query every mounted device directly and render each exactly once.
for device in _rack_elevation_devices(elevation.rack, face):
try:
placement = device.netbox_utilities_rack_placement
except ObjectDoesNotExist:
placement = None
devices = list(_rack_elevation_devices(elevation.rack, face))
widths = effective_width_positions(devices)
for device in devices:
width_divisor, horizontal_position, _source = widths[device.pk]
height = decimal.Decimal(str(device.device_type.u_height))
coords = elevation._get_device_coords(device.position, height)
width = elevation.unit_width if placement is None else elevation.unit_width / placement.width
if placement is not None:
coords = (coords[0] + width * (placement.horizontal_position - 1), coords[1])
width = elevation.unit_width / width_divisor
coords = (coords[0] + width * (horizontal_position - 1), coords[1])
size = (width, int(elevation.unit_height * height))
if device.pk in elevation.permitted_device_ids:
if device.face == face and not opposite:
@@ -469,7 +527,7 @@ def _install_rack_svg():
RackElevationSVG.draw_face = width_aware_draw_face
RackElevationSVG._netbox_utilities_rack_width_installed = True
RackElevationSVG._netbox_utilities_rack_width_version = 3
RackElevationSVG._netbox_utilities_rack_width_version = 4
def _rack_elevation_devices(rack, face):
+14 -15
View File
@@ -16,7 +16,7 @@ from rest_framework.response import Response
from utilities.permissions import get_permission_for_model
from .models import DeviceRackPlacement
from .rack_width import FULL_WIDTH, normalize_width_position, stage_width_position
from .rack_width import FULL_WIDTH, effective_width_positions, normalize_width_position, stage_width_position
logger = logging.getLogger(__name__)
@@ -116,11 +116,14 @@ def apply_rack_layout(*, rack, placements, user):
placement.device_id: placement
for placement in DeviceRackPlacement.objects.select_for_update().filter(device_id__in=placement_by_id)
}
mounted_devices = [device for device in devices.values() if device.position is not None and device.position > 0]
effective_placements = effective_width_positions(mounted_devices, stored_placements)
target_placements = []
for placement in placements:
stored = stored_placements.get(placement.device_id)
current_width = stored.width if stored else FULL_WIDTH
current_horizontal_position = stored.horizontal_position if stored else 1
current_width, current_horizontal_position, _source = effective_placements.get(
placement.device_id,
(FULL_WIDTH, 1, "default"),
)
if placement.position is None:
target_width, target_horizontal_position = FULL_WIDTH, 1
elif placement.grid_x is None:
@@ -238,16 +241,6 @@ def _device_label(device):
return device.label or str(device.device_type)
def _device_width_position(device):
try:
placement = device.netbox_utilities_rack_placement
except ObjectDoesNotExist:
placement = None
if placement is None:
return FULL_WIDTH, 1
return normalize_width_position(placement.width, placement.horizontal_position)
def _empty_reorder_rack_width_data(request, *, status="ready"):
selected_view = request.GET.get("view", "images-and-labels")
return {
@@ -257,6 +250,8 @@ def _empty_reorder_rack_width_data(request, *, status="ready"):
"labels": selected_view != "images-only",
"devices": [],
"status": status,
"schema_version": 3,
"complete": False,
}
@@ -294,9 +289,11 @@ def get_reorder_rack_width_data(request):
)
.order_by("position", "pk")
)
devices = list(devices)
widths = effective_width_positions(devices)
permission = get_permission_for_model(Device, "change")
for device in devices:
width, horizontal_position = _device_width_position(device)
width, horizontal_position, width_source = widths[device.pk]
grid_width, grid_x = grid_dimensions(width, horizontal_position)
role_color = device.role.color or "1685fc"
grid_height = int(Decimal(str(device.device_type.u_height)) * 2)
@@ -312,6 +309,7 @@ def get_reorder_rack_width_data(request):
"grid_height": grid_height,
"width": width,
"horizontal_position": horizontal_position,
"width_source": width_source,
"color": role_color,
"text_color": foreground_color(role_color),
"front_image": _image_url(device.device_type.front_image),
@@ -320,6 +318,7 @@ def get_reorder_rack_width_data(request):
}
)
data["unit_width"] = get_config().RACK_ELEVATION_DEFAULT_UNIT_WIDTH
data["complete"] = True
return data
except (
AttributeError,
@@ -15,6 +15,22 @@
console.warn('NetBox Utilities is leaving Reorder Rack in its native layout.', data.status);
return;
}
if (data.schema_version !== 3 || data.complete !== true || !Array.isArray(data.devices)) {
console.warn('NetBox Utilities received an incomplete Reorder Rack payload and kept the native layout.');
return;
}
const validDeviceData = data.devices.every(device => (
Number.isInteger(Number(device.id))
&& ['front', 'rear'].includes(device.face)
&& Number.isInteger(Number(device.grid_x))
&& Number.isInteger(Number(device.grid_y))
&& Number.isInteger(Number(device.grid_width))
&& Number.isInteger(Number(device.grid_height))
));
if (!validDeviceData) {
console.warn('NetBox Utilities received invalid Reorder Rack device data and kept the native layout.');
return;
}
const columns = Number.parseInt(data.columns, 10) || 12;
const gridElements = {
@@ -24,6 +40,15 @@
};
if (!gridElements.front || !gridElements.rear || !gridElements.other) return;
const nativeMountedItems = [
...gridElements.front.querySelectorAll('.grid-stack-item'),
...gridElements.rear.querySelectorAll('.grid-stack-item'),
];
if (!data.devices.length && nativeMountedItems.length) {
console.warn('NetBox Utilities received no replacement devices and kept the populated native layout.');
return;
}
const setAttribute = (element, name, value) => element.setAttribute(name, String(value));
const originalFetch = window.fetch.bind(window);
window.fetch = (resource, options = {}) => {
@@ -11,6 +11,10 @@
console.error('NetBox Utilities could not read the Topology Views rack widths.', error);
return;
}
if (!Array.isArray(payload) && (payload?.schema_version !== 3 || payload?.complete !== true)) {
console.warn('NetBox Utilities received an incomplete Topology Views payload and kept the native layout.');
return;
}
if (!Array.isArray(payload) && payload?.status && payload.status !== 'ready') {
console.warn('NetBox Utilities is leaving Topology Views in its native layout.', payload.status);
return;
@@ -36,6 +40,7 @@
return {
width,
horizontalPosition,
source: String(value.width_source || 'stored'),
leftPercent: Number.isFinite(Number(value.left_percent))
? Number(value.left_percent)
: (horizontalPosition - 1) / width * 100,
@@ -87,6 +92,7 @@
device.classList.add('netbox-utilities-partial-width');
device.dataset.rackWidth = String(placement.width);
device.dataset.horizontalPosition = String(placement.horizontalPosition);
device.dataset.rackWidthSource = placement.source;
device.style.setProperty('--netbox-utilities-rack-device-left', `${placement.leftPercent}%`);
device.style.setProperty('--netbox-utilities-rack-device-width', `${placement.widthPercent}%`);
});
@@ -28,6 +28,8 @@ class OptionalRackIntegrationHeadTest(SimpleTestCase):
"labels": True,
"devices": [],
"status": "ready",
"schema_version": 3,
"complete": True,
}
with (
patch("netbox_utilities.template_content.get_reorder_rack_width_data", return_value=data),
@@ -40,7 +42,7 @@ class OptionalRackIntegrationHeadTest(SimpleTestCase):
self.assertIn('"status": "ready"', html)
def test_topology_adapter_is_emitted_for_an_empty_enabled_payload(self):
data = {"devices": [], "status": "ready"}
data = {"devices": [], "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),
+60
View File
@@ -17,6 +17,7 @@ from netbox_utilities.rack_width import (
_install_rack_methods,
_rack_elevation_devices,
available_units_for_device,
effective_width_positions,
horizontal_interval,
include_rack_width_in_fieldsets,
intervals_cover_full_width,
@@ -84,6 +85,7 @@ class RackWidthTest(SimpleTestCase):
regular = SimpleNamespace(
pk=1,
rack_id=3,
face="front",
position=12,
device_type=SimpleNamespace(u_height=1),
@@ -91,6 +93,7 @@ class RackWidthTest(SimpleTestCase):
)
partial_right = SimpleNamespace(
pk=2,
rack_id=3,
face="front",
position=11,
device_type=SimpleNamespace(u_height=1),
@@ -98,6 +101,7 @@ class RackWidthTest(SimpleTestCase):
)
partial_left = SimpleNamespace(
pk=3,
rack_id=3,
face="front",
position=11,
device_type=SimpleNamespace(u_height=1),
@@ -131,6 +135,62 @@ class RackWidthTest(SimpleTestCase):
],
)
def test_infers_two_half_widths_when_legacy_devices_share_one_unit(self):
devices = [
SimpleNamespace(
pk=21,
rack_id=3,
face="front",
position=11,
device_type=SimpleNamespace(u_height=1),
netbox_utilities_rack_placement=None,
),
SimpleNamespace(
pk=22,
rack_id=3,
face="front",
position=11,
device_type=SimpleNamespace(u_height=1),
netbox_utilities_rack_placement=None,
),
]
self.assertEqual(
effective_width_positions(devices),
{
21: (2, 1, "inferred"),
22: (2, 2, "inferred"),
},
)
def test_infers_missing_position_next_to_stored_half_width(self):
devices = [
SimpleNamespace(
pk=31,
rack_id=3,
face="front",
position=11,
device_type=SimpleNamespace(u_height=1),
netbox_utilities_rack_placement=SimpleNamespace(width=2, horizontal_position=2),
),
SimpleNamespace(
pk=32,
rack_id=3,
face="front",
position=11,
device_type=SimpleNamespace(u_height=1),
netbox_utilities_rack_placement=None,
),
]
self.assertEqual(
effective_width_positions(devices),
{
31: (2, 2, "stored"),
32: (2, 1, "inferred"),
},
)
def test_adds_rack_width_fields_to_existing_location_fieldset(self):
from utilities.forms.rendering import FieldSet
+93 -5
View File
@@ -97,8 +97,20 @@ class ApplyRackLayoutTest(SimpleTestCase):
placement_model,
):
events = []
first = MagicMock(pk=10, position=Decimal(1), face="front")
second = MagicMock(pk=11, position=Decimal(2), face="front")
first = MagicMock(
pk=10,
rack_id=5,
position=Decimal(1),
face="front",
device_type=SimpleNamespace(u_height=1),
)
second = MagicMock(
pk=11,
rack_id=5,
position=Decimal(2),
face="front",
device_type=SimpleNamespace(u_height=1),
)
first.snapshot.side_effect = lambda: events.append("snapshot-10")
second.snapshot.side_effect = lambda: events.append("snapshot-11")
first.full_clean.side_effect = lambda: events.append("validate-10")
@@ -126,7 +138,13 @@ class ApplyRackLayoutTest(SimpleTestCase):
@patch("netbox_utilities.reorder_rack.get_permission_for_model", return_value="dcim.change_device")
@patch("netbox_utilities.reorder_rack.Device")
def test_rejects_changed_device_without_permission(self, device_model, _get_permission, placement_model):
device = MagicMock(pk=10, position=Decimal(1), face="front")
device = MagicMock(
pk=10,
rack_id=5,
position=Decimal(1),
face="front",
device_type=SimpleNamespace(u_height=1),
)
device_model.objects.select_for_update.return_value.filter.return_value = [device]
placement_model.objects.select_for_update.return_value.filter.return_value = []
user = MagicMock()
@@ -148,7 +166,13 @@ class ApplyRackLayoutTest(SimpleTestCase):
_get_permission,
placement_model,
):
device = MagicMock(pk=10, position=Decimal(4), face="front")
device = MagicMock(
pk=10,
rack_id=5,
position=Decimal(4),
face="front",
device_type=SimpleNamespace(u_height=1),
)
stored = SimpleNamespace(device_id=10, width=2, horizontal_position=1)
device_model.objects.select_for_update.return_value.filter.return_value = [device]
placement_model.objects.select_for_update.return_value.filter.return_value = [stored]
@@ -178,7 +202,13 @@ class ApplyRackLayoutTest(SimpleTestCase):
_get_permission,
placement_model,
):
device = MagicMock(pk=10, position=Decimal(4), face="front")
device = MagicMock(
pk=10,
rack_id=5,
position=Decimal(4),
face="front",
device_type=SimpleNamespace(u_height=1),
)
stored = SimpleNamespace(device_id=10, width=2, horizontal_position=2)
device_model.objects.select_for_update.return_value.filter.return_value = [device]
placement_model.objects.select_for_update.return_value.filter.return_value = [stored]
@@ -194,6 +224,51 @@ class ApplyRackLayoutTest(SimpleTestCase):
defaults={"width": 2, "horizontal_position": 2},
)
@patch("netbox_utilities.reorder_rack.DeviceRackPlacement")
@patch("netbox_utilities.reorder_rack.get_permission_for_model", return_value="dcim.change_device")
@patch("netbox_utilities.reorder_rack.Device")
def test_persists_inferred_half_width_when_shared_device_is_moved(
self,
device_model,
_get_permission,
placement_model,
):
left = MagicMock(
pk=10,
rack_id=5,
position=Decimal(4),
face="front",
device_type=SimpleNamespace(u_height=1),
)
right = MagicMock(
pk=11,
rack_id=5,
position=Decimal(4),
face="front",
device_type=SimpleNamespace(u_height=1),
)
device_model.objects.select_for_update.return_value.filter.return_value = [left, right]
placement_model.objects.select_for_update.return_value.filter.return_value = []
user = MagicMock()
user.has_perm.return_value = True
placements = parse_device_placements(
rack_layout(
front=[
{"id": 10, "x": 0, "y": 4},
{"id": 11, "x": 6, "y": 5},
]
),
grid_columns=12,
)
changed = apply_rack_layout(rack=SimpleNamespace(pk=5), placements=placements, user=user)
self.assertEqual(changed, [right])
placement_model.objects.update_or_create.assert_called_once_with(
device=right,
defaults={"width": 2, "horizontal_position": 2},
)
class ReorderRackFrontendTest(SimpleTestCase):
def test_grid_y_matches_reorder_plugin_for_both_numbering_directions(self):
@@ -213,6 +288,9 @@ class ReorderRackFrontendTest(SimpleTestCase):
self.assertIn("closest('#saveButton')", script)
self.assertIn("X-NetBox-Utilities-Rack-Grid-Columns", script)
self.assertIn("if (face !== 'other')", script)
self.assertIn("data.schema_version !== 3", script)
self.assertIn("data.complete !== true", script)
self.assertIn("nativeMountedItems.length", script)
self.assertNotIn("partialDeviceIds", script)
self.assertNotIn("JSON.stringify", script)
@@ -229,6 +307,7 @@ class ReorderRackFrontendTest(SimpleTestCase):
self.assertEqual(result["status"], "native-fallback")
self.assertEqual(result["devices"], [])
self.assertFalse(result["complete"])
@patch("netbox_utilities.reorder_rack.reorder_rack_width_enabled", return_value=True)
@patch("netbox_utilities.reorder_rack.get_permission_for_model", return_value="dcim.change_device")
@@ -250,6 +329,7 @@ class ReorderRackFrontendTest(SimpleTestCase):
placement = SimpleNamespace(width=2, horizontal_position=2)
device = SimpleNamespace(
pk=10,
rack_id=5,
name="Fritzbox",
label="Fritzbox",
virtual_chassis=None,
@@ -266,6 +346,7 @@ class ReorderRackFrontendTest(SimpleTestCase):
)
adjacent_device = SimpleNamespace(
pk=12,
rack_id=5,
name="Router",
label="Router",
virtual_chassis=None,
@@ -282,6 +363,7 @@ class ReorderRackFrontendTest(SimpleTestCase):
)
full_width_device = SimpleNamespace(
pk=11,
rack_id=5,
name="Switch",
label="Switch",
virtual_chassis=None,
@@ -315,6 +397,8 @@ class ReorderRackFrontendTest(SimpleTestCase):
self.assertEqual(result["columns"], 12)
self.assertEqual(result["status"], "ready")
self.assertEqual(result["schema_version"], 3)
self.assertTrue(result["complete"])
self.assertEqual(len(result["devices"]), 3)
self.assertEqual(result["devices"][0]["label"], "Fritzbox")
self.assertFalse(result["images"])
@@ -336,6 +420,10 @@ class ReorderRackFrontendTest(SimpleTestCase):
{key: result["devices"][2][key] for key in ("id", "grid_x", "grid_width", "width")},
{"id": 11, "grid_x": 0, "grid_width": 12, "width": 1},
)
self.assertEqual(
[device["width_source"] for device in result["devices"]],
["stored", "stored", "default"],
)
class ReorderRackPatchTest(SimpleTestCase):
+37 -16
View File
@@ -43,7 +43,7 @@ class TopologyViewsRackWidthTest(SimpleTestCase):
def test_serializes_fractional_width_geometry_and_device_url(self):
placement = SimpleNamespace(
device_id=17,
device=SimpleNamespace(get_absolute_url=lambda: "/dcim/devices/17/"),
device=SimpleNamespace(pk=17, get_absolute_url=lambda: "/dcim/devices/17/"),
width=3,
horizontal_position=2,
)
@@ -57,12 +57,12 @@ class TopologyViewsRackWidthTest(SimpleTestCase):
"horizontal_position": 2,
"left_percent": 33.33333333,
"width_percent": 33.33333333,
"width_source": "stored",
},
)
@patch("netbox_utilities.topology_views.DeviceRackPlacement.objects")
@patch("netbox_utilities.topology_views.topology_rack_width_enabled", return_value=True)
def test_queries_only_permitted_devices_from_selected_racks(self, _enabled, placement_objects):
def test_queries_all_permitted_devices_and_infers_shared_unit_widths(self, _enabled):
self.request.resolver_match = SimpleNamespace(view_name="plugins:netbox_topology_views:rack_elevation")
rack_queryset = MagicMock()
rack_queryset.filter.return_value = rack_queryset
@@ -71,19 +71,32 @@ class TopologyViewsRackWidthTest(SimpleTestCase):
rack_model.objects.restrict.return_value = rack_queryset
device_queryset = MagicMock()
device_queryset.filter.return_value = "permitted-device-query"
selected_devices = MagicMock()
device_queryset.filter.return_value = selected_devices
device_model = MagicMock()
device_model.objects.restrict.return_value = device_queryset
placement = SimpleNamespace(
device_id=17,
device=SimpleNamespace(get_absolute_url=lambda: "/dcim/devices/17/"),
width=2,
horizontal_position=2,
)
placement_queryset = MagicMock()
placement_queryset.select_related.return_value.order_by.return_value = [placement]
placement_objects.filter.return_value = placement_queryset
devices = [
SimpleNamespace(
pk=17,
rack_id=3,
face="front",
position=11,
device_type=SimpleNamespace(u_height=1),
netbox_utilities_rack_placement=None,
get_absolute_url=lambda: "/dcim/devices/17/",
),
SimpleNamespace(
pk=18,
rack_id=3,
face="front",
position=11,
device_type=SimpleNamespace(u_height=1),
netbox_utilities_rack_placement=None,
get_absolute_url=lambda: "/dcim/devices/18/",
),
]
selected_devices.select_related.return_value.order_by.return_value = devices
with patch.dict("sys.modules", {"dcim.models": SimpleNamespace(Device=device_model, Rack=rack_model)}):
result = get_topology_rack_width_data(self.request)
@@ -91,12 +104,17 @@ class TopologyViewsRackWidthTest(SimpleTestCase):
rack_queryset.filter.assert_called_once_with(pk__in=["3"])
device_queryset.filter.assert_called_once_with(
rack_id__in="selected-rack-query",
position__isnull=False,
position__gt=0,
device_type__u_height__gt=0,
)
placement_objects.filter.assert_called_once_with(device__in="permitted-device-query")
self.assertEqual(result["status"], "ready")
self.assertEqual(result["schema_version"], 3)
self.assertTrue(result["complete"])
self.assertEqual(len(result["devices"]), 2)
self.assertEqual(result["devices"][0]["width"], 2)
self.assertEqual(result["devices"][0]["horizontal_position"], 2)
self.assertEqual(result["devices"][0]["horizontal_position"], 1)
self.assertEqual(result["devices"][1]["horizontal_position"], 2)
self.assertEqual(result["devices"][0]["width_source"], "inferred")
def test_frontend_supports_live_view_and_all_rack_export_formats(self):
script = (Path(__file__).parents[1] / "static" / "netbox_utilities" / "topology-rack-width.js").read_text(
@@ -115,6 +133,8 @@ class TopologyViewsRackWidthTest(SimpleTestCase):
self.assertIn("liveViewCompatible", script)
self.assertIn("payload?.devices", script)
self.assertIn("payload.status !== 'ready'", script)
self.assertIn("payload?.schema_version !== 3", script)
self.assertIn("placement.source", script)
self.assertLess(script.index("liveViewCompatible"), script.index("netbox-utilities-partial-width"))
self.assertLess(script.index("exportGraphic(graphic"), script.index("event.preventDefault()"))
@@ -126,6 +146,7 @@ class TopologyViewsRackWidthTest(SimpleTestCase):
self.assertEqual(result["status"], "native-fallback")
self.assertEqual(result["devices"], [])
self.assertFalse(result["complete"])
def test_partial_width_css_uses_topology_device_slot_geometry(self):
stylesheet = (Path(__file__).parents[1] / "static" / "netbox_utilities" / "netbox_utilities.css").read_text(
+31 -11
View File
@@ -5,7 +5,7 @@ from django.core.exceptions import ObjectDoesNotExist
from django.db import OperationalError, ProgrammingError
from netbox.plugins import get_plugin_config
from .models import DeviceRackPlacement
from .rack_width import effective_width_positions
logger = logging.getLogger(__name__)
@@ -27,15 +27,25 @@ def topology_rack_width_enabled(request):
def serialize_topology_placement(placement):
width = int(placement.width)
horizontal_position = int(placement.horizontal_position)
return serialize_topology_width(
placement.device,
placement.width,
placement.horizontal_position,
source="stored",
)
def serialize_topology_width(device, width, horizontal_position, *, source):
width = int(width)
horizontal_position = int(horizontal_position)
return {
"device_id": placement.device_id,
"url": placement.device.get_absolute_url(),
"device_id": device.pk,
"url": device.get_absolute_url(),
"width": width,
"horizontal_position": horizontal_position,
"left_percent": round((horizontal_position - 1) / width * 100, 8),
"width_percent": round(100 / width, 8),
"width_source": source,
}
@@ -44,7 +54,7 @@ def get_topology_rack_width_data(request):
if not topology_rack_width_enabled(request):
return None
data = {"devices": [], "status": "ready"}
data = {"devices": [], "status": "ready", "schema_version": 3, "complete": False}
if not request.GET:
return data
@@ -63,14 +73,24 @@ def get_topology_rack_width_data(request):
if selected_locations:
racks = racks.filter(location_id__in=selected_locations)
devices = Device.objects.restrict(request.user, "view").filter(
devices = list(
Device.objects.restrict(request.user, "view")
.filter(
rack_id__in=racks.values("pk"),
position__isnull=False,
position__gt=0,
device_type__u_height__gt=0,
)
placements = (
DeviceRackPlacement.objects.filter(device__in=devices).select_related("device").order_by("device_id")
.select_related("device_type", "netbox_utilities_rack_placement")
.order_by("rack_id", "face", "position", "pk")
)
data["devices"] = [serialize_topology_placement(placement) for placement in placements]
widths = effective_width_positions(devices)
data["devices"] = [
serialize_topology_width(device, width, position, source=source)
for device in devices
for width, position, source in (widths[device.pk],)
if width > 1
]
data["complete"] = True
return data
except (
AttributeError,
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "netbox-utilities"
version = "0.9.2"
version = "0.9.3"
description = "Navigation, tenant utilities, partial-width rack devices, bulk uploads, and rack reordering for NetBox 4.6"
readme = "README.md"
requires-python = ">=3.12"