fix: restore populated rack and reorder views
This commit is contained in:
@@ -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,6 +1,6 @@
|
||||
from netbox.plugins import PluginConfig, get_plugin_config
|
||||
|
||||
__version__ = "0.9.2"
|
||||
__version__ = "0.9.3"
|
||||
|
||||
|
||||
class NetBoxUtilitiesConfig(PluginConfig):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
rack_id__in=racks.values("pk"),
|
||||
position__isnull=False,
|
||||
devices = list(
|
||||
Device.objects.restrict(request.user, "view")
|
||||
.filter(
|
||||
rack_id__in=racks.values("pk"),
|
||||
position__gt=0,
|
||||
device_type__u_height__gt=0,
|
||||
)
|
||||
.select_related("device_type", "netbox_utilities_rack_placement")
|
||||
.order_by("rack_id", "face", "position", "pk")
|
||||
)
|
||||
placements = (
|
||||
DeviceRackPlacement.objects.filter(device__in=devices).select_related("device").order_by("device_id")
|
||||
)
|
||||
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
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user