feat: integrate rack widths across plugins

This commit is contained in:
2026-08-12 17:12:19 +02:00
parent ec67e6b7d0
commit 70825c4040
14 changed files with 1267 additions and 43 deletions
+58 -9
View File
@@ -1,6 +1,6 @@
# NetBox Utilities
Plugin für **NetBox 4.6.5 bis 4.6.7** mit neun Funktionen:
Plugin für **NetBox 4.6.5 bis 4.6.7** mit zehn Funktionen:
- Jeder Benutzer kann die Menüs der linken Navigation verschieben oder ausblenden.
- Ein Dropdown in der Kopfleiste setzt einen sitzungsweiten Filter für einen Mandanten oder eine Mandantengruppe.
@@ -8,6 +8,7 @@ Plugin für **NetBox 4.6.5 bis 4.6.7** mit neun Funktionen:
- Automatische Vorbelegung von Mandant und Mandantengruppe aus dem Objekt- oder Filterkontext.
- Automatische 1:1-Verknüpfung von Front- und Rearports auf Geräten mit der Rolle `Patchpanel`.
- Geräte mit optionaler Teilbreite können sich dieselbe Höheneinheit teilen.
- Die Rack-Ansicht von MrBlakes optionalem Topology-Views-Plugin stellt Teilbreitengeräte korrekt dar.
- Mehrere Bilder in einem Schritt im Bilder-Tab eines Objekts hochladen.
- Mehrere Module desselben Typs in einem Schritt in freie Modulschächte einbauen.
- Optionale Mehrfachspeicherung für verschobene Geräte aus NetBox Reorder Rack.
@@ -19,6 +20,7 @@ Die Navigationseinstellungen sind benutzerbezogen. Die aktive Mandanten- oder Gr
- NetBox `>=4.6.5,<4.7`
- Python `>=3.12`
- optional: NetBox Reorder Rack `1.1.4`
- optional: [MrBlake NetBox Topology Views](https://git.mrblake.cc/MrBlake/mrb-netbox-topology-views) mit Rack-Ansicht
Andere NetBox-Versionen werden vom Plugin absichtlich abgelehnt, da die Anpassung der Core-Navigation von deren HTML-Struktur abhängt.
@@ -32,11 +34,12 @@ Das Plugin kann direkt aus dem `main`-Branch installiert werden:
```
Für eine reproduzierbare Produktivinstallation sollte statt `main` ein
Release-Tag oder ein bestimmter Commit verwendet werden:
bereits veröffentlichter Release-Tag oder eine bestimmte Commit-ID verwendet
werden. `COMMIT-ID` wird dabei durch den gewünschten Stand ersetzt:
```bash
/opt/netbox/venv/bin/pip install --upgrade --force-reinstall \
"git+https://git.mrblake.cc/MrBlake/Netbox-Utilities.git@v0.8.8"
"git+https://git.mrblake.cc/MrBlake/Netbox-Utilities.git@COMMIT-ID"
```
Alternativ kann hinter dem `@` die vollständige Commit-ID stehen.
@@ -69,6 +72,7 @@ PLUGINS_CONFIG = {
"netbox_utilities": {
"navigation_customization_enabled": True,
"reorder_rack_bulk_save_enabled": True,
"topology_views_rack_width_enabled": True,
"tenant_filter_enabled": True,
"tenant_required": True,
},
@@ -135,9 +139,9 @@ empfohlen.
### Mehrere Geräte nebeneinander in derselben HE
Auf der normalen **Bearbeitungsseite eines Geräts** stehen direkt nach
**Position** zwei neue optionale Felder zur Verfügung. Dafür wird kein
separates Plugin-Menü benötigt:
Auf der normalen Seite zum **Anlegen oder Bearbeiten eines Geräts** stehen
direkt nach **Position** zwei neue optionale Felder zur Verfügung. Dafür wird
kein separates Plugin-Menü benötigt:
- **Rackbreite**: volle, halbe, Drittel- oder Viertelbreite;
- **Breitenposition**: Position 1 bis 4, von links gezählt.
@@ -169,6 +173,44 @@ wiederhergestellt.
Die Zusatzfelder werden derzeit im NetBox-Webformular gepflegt. REST- oder
CSV-Vorgänge ohne diese Felder behandeln neue Geräte als volle Rackbreite.
### Teilbreiten in MrBlake NetBox Topology Views
Ist
[MrBlake NetBox Topology Views](https://git.mrblake.cc/MrBlake/mrb-netbox-topology-views)
mit seiner **Rack-Ansicht** installiert, erkennt NetBox Utilities das Plugin
automatisch. Teilbreitengeräte werden dort anhand ihrer Rackbreite und
Breitenposition nebeneinander dargestellt. Die aktuelle Rack-Ansicht wird auch
bei ihren Exporten nach **SVG**, **PNG** und **draw.io** unterstützt. Die
Kabeltopologie bleibt unverändert, da sie Geräte als frei verschiebbare Knoten
und nicht als physische Rackflächen darstellt.
Das Topology-Plugin bleibt eine optionale Abhängigkeit. Eine reproduzierbare
Installation des derzeit getesteten MrBlake-Stands erfolgt über dessen
Commit-ID:
```bash
/opt/netbox/venv/bin/pip install --upgrade --force-reinstall \
"git+https://git.mrblake.cc/MrBlake/mrb-netbox-topology-views.git@86a8daf45ef380d57ac73b461dbc47a16bb831ba"
```
Für automatische NetBox-Updates wird derselbe Git-Eintrag zusätzlich in
`/opt/netbox/local_requirements.txt` aufgenommen. Anschließend müssen beide
Plugins aktiviert sein; ihre Reihenfolge ist beliebig:
```python
PLUGINS = [
"netbox_topology_views",
"netbox_utilities",
]
```
Die Integration ändert keine Dateien von Topology Views und erzeugt keine
zusätzlichen Datenbankeinträge. Sie wird ausschließlich auf dessen Rack-Seite
geladen und kann mit
`topology_views_rack_width_enabled = False` in `PLUGINS_CONFIG` deaktiviert
werden. Ist Topology Views nicht installiert oder enthält der installierte
Stand keine Rack-Ansicht, bleibt NetBox Utilities unverändert nutzbar.
### Front- und Rearports von Patchpaneln automatisch verknüpfen
Geräte, deren NetBox-Geräterolle `Patchpanel` heißt, werden automatisch als
@@ -195,6 +237,13 @@ Wenn `netbox-reorder-rack` in Version `1.1.4` installiert und aktiviert ist,
erweitert NetBox Utilities dessen vorhandenen Speichervorgang automatisch.
In der Drag-and-drop-Ansicht können mehrere Geräte verschoben oder miteinander
getauscht und anschließend gemeinsam über **Save** gespeichert werden.
Teilbreitengeräte werden dort ebenfalls nebeneinander angezeigt. Das
12-spaltige Raster bildet volle, halbe, Drittel- und Viertelbreite exakt ab.
Beim horizontalen Verschieben rastet ein Gerät auf einer für seine Breite
gültigen Position ein; **Save** speichert Höhe, Rackseite und Breitenposition
gemeinsam in derselben Transaktion. Die gewählte Rackbreite selbst wird in der
Reorder-Ansicht nicht verändert und weiterhin auf der Geräte-Neu- oder
Bearbeitungsseite gepflegt.
Die optionale Abhängigkeit wird separat installiert und für spätere NetBox-
Updates in `/opt/netbox/local_requirements.txt` aufgenommen:
@@ -214,8 +263,8 @@ PLUGINS = [
```
NetBox Utilities verändert keine Dateien des Reorder-Plugins. Stattdessen wird
dessen API beim Start kompatibel erweitert. Beim Speichern werden alle
betroffenen Geräte und das Rack gesperrt, die alten Positionen gemeinsam
dessen Oberfläche und API beim Start kompatibel erweitert. Beim Speichern
werden alle betroffenen Geräte und das Rack gesperrt, die alten Positionen gemeinsam
freigegeben und danach sämtliche Zielpositionen mit der normalen
NetBox-Modellvalidierung geprüft. Der Vorgang läuft in einer Transaktion: Bei
einem Platz-, Validierungs- oder Berechtigungsfehler bleibt das gesamte Rack
@@ -228,7 +277,7 @@ Andere Versionen als `1.1.4` werden aus Sicherheitsgründen nicht automatisch
verändert und erzeugen lediglich einen Hinweis im NetBox-Log. Das
Reorder-Projekt selbst weist derzeit offiziell nur Kompatibilität bis NetBox
4.5 aus; die Erweiterung in diesem Plugin ist gezielt für die hier unterstützte
NetBox-Version 4.6.5 umgesetzt.
NetBox-Versionen 4.6.5 bis 4.6.7 umgesetzt und getestet.
### Mehrere Bilder hochladen
+2 -1
View File
@@ -1,6 +1,6 @@
from netbox.plugins import PluginConfig, get_plugin_config
__version__ = "0.8.8"
__version__ = "0.9.0"
class NetBoxUtilitiesConfig(PluginConfig):
@@ -15,6 +15,7 @@ class NetBoxUtilitiesConfig(PluginConfig):
default_settings = {
"navigation_customization_enabled": True,
"reorder_rack_bulk_save_enabled": True,
"topology_views_rack_width_enabled": True,
"tenant_filter_enabled": True,
"tenant_required": True,
}
+15 -4
View File
@@ -1,4 +1,5 @@
import decimal
import logging
from contextvars import ContextVar
from copy import deepcopy
from fractions import Fraction
@@ -11,6 +12,8 @@ from django.db.models.signals import post_save, pre_save
from .models import DeviceRackPlacement
logger = logging.getLogger(__name__)
FULL_WIDTH = 1
WIDTH_CHOICES = (
(FULL_WIDTH, "Volle Rackbreite"),
@@ -432,14 +435,22 @@ def _install_device_form():
RackWidthDeviceEditView.__module__ = __name__
RackWidthDeviceEditView.__qualname__ = "RackWidthDeviceEditView"
replaced_views = set()
for view_config in registry["views"]["dcim"]["device"]:
if view_config["name"] == "edit":
if view_config["name"] in {"add", "edit"}:
view_config["view"] = RackWidthDeviceEditView
break
else:
raise RuntimeError("Die NetBox-Gerätebearbeitung ist nicht in der View-Registry registriert.")
replaced_views.add(view_config["name"])
if replaced_views != {"add", "edit"}:
missing_views = ", ".join(sorted({"add", "edit"} - replaced_views))
logger.warning(
"NetBox Utilities could not replace these device registry views: %s; "
"the core DeviceEditView fallback remains active",
missing_views,
)
DeviceEditView.form = RackWidthDeviceForm
DeviceEditView.template_name = RackWidthDeviceEditView.template_name
DeviceEditView.htmx_template_name = RackWidthDeviceEditView.htmx_template_name
DeviceEditView._netbox_utilities_rack_width_installed = True
+213 -14
View File
@@ -5,19 +5,25 @@ from importlib import import_module
from dcim.models import Device, Rack
from django.apps import apps
from django.core.exceptions import ValidationError
from django.db import transaction
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.db import OperationalError, ProgrammingError, transaction
from django.http import Http404
from django.shortcuts import get_object_or_404
from netbox.plugins import get_plugin_config
from rest_framework import status
from rest_framework.exceptions import PermissionDenied
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
logger = logging.getLogger(__name__)
SUPPORTED_REORDER_RACK_VERSIONS = {"1.1.4"}
PATCH_MARKER = "_netbox_utilities_bulk_save"
GRID_COLUMNS = 12
REORDER_RACK_VIEW = "dcim:rack_reorder"
class RackLayoutError(Exception):
@@ -29,9 +35,31 @@ class DevicePlacement:
device_id: int
position: Decimal | None
face: str
grid_x: int | None
def parse_device_placements(layout):
def grid_dimensions(width, horizontal_position):
try:
width, horizontal_position = normalize_width_position(width, horizontal_position)
except ValidationError as error:
raise RackLayoutError(str(error)) from error
grid_width = GRID_COLUMNS // width
return grid_width, (horizontal_position - 1) * grid_width
def horizontal_position_from_grid_x(width, grid_x):
if isinstance(grid_x, bool) or not isinstance(grid_x, int):
raise RackLayoutError("Die horizontale Rasterposition muss eine ganze Zahl sein.")
grid_width, _ = grid_dimensions(width, 1)
if grid_x < 0 or grid_x > GRID_COLUMNS - grid_width or grid_x % grid_width:
raise RackLayoutError(f"Rasterposition {grid_x} ist für 1/{width} Rackbreite nicht zulässig.")
return grid_x // grid_width + 1
def parse_device_placements(layout, *, grid_columns=None):
if grid_columns not in {None, GRID_COLUMNS}:
raise RackLayoutError(f"Das Rackraster muss {GRID_COLUMNS} Spalten verwenden.")
placements = []
seen_device_ids = set()
@@ -53,6 +81,7 @@ def parse_device_placements(layout):
if section == "other":
position = None
grid_x = None
else:
try:
position = Decimal(str(item["y"]))
@@ -61,7 +90,13 @@ def parse_device_placements(layout):
if not position.is_finite():
raise RackLayoutError(f"Gerät {device_id} besitzt keine gültige Rack-Position.")
placements.append(DevicePlacement(device_id=device_id, position=position, face=face))
grid_x = None
if grid_columns == GRID_COLUMNS:
grid_x = item.get("x")
if isinstance(grid_x, bool) or not isinstance(grid_x, int) or not 0 <= grid_x < GRID_COLUMNS:
raise RackLayoutError(f"Gerät {device_id} besitzt keine gültige horizontale Rasterposition.")
placements.append(DevicePlacement(device_id=device_id, position=position, face=face, grid_x=grid_x))
return placements
@@ -77,35 +112,81 @@ def apply_rack_layout(*, rack, placements, user):
missing = ", ".join(str(device_id) for device_id in sorted(missing_ids))
raise RackLayoutError(f"Diese Geräte gehören nicht zum gewählten Rack: {missing}.")
stored_placements = {
placement.device_id: placement
for placement in DeviceRackPlacement.objects.select_for_update().filter(device_id__in=placement_by_id)
}
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
if placement.position is None:
target_width, target_horizontal_position = FULL_WIDTH, 1
elif placement.grid_x is None:
target_width = current_width
target_horizontal_position = current_horizontal_position
else:
target_width = current_width
target_horizontal_position = horizontal_position_from_grid_x(target_width, placement.grid_x)
target_placements.append(
(
devices[placement.device_id],
placement,
target_width,
target_horizontal_position,
current_width,
current_horizontal_position,
)
)
changed = [
(devices[placement.device_id], placement)
for placement in placements
(device, placement, target_width, target_horizontal_position)
for (
device,
placement,
target_width,
target_horizontal_position,
current_width,
current_horizontal_position,
) in target_placements
if (
devices[placement.device_id].position != placement.position
or devices[placement.device_id].face != placement.face
device.position != placement.position
or device.face != placement.face
or current_width != target_width
or current_horizontal_position != target_horizontal_position
)
]
if not changed:
return []
permission = get_permission_for_model(Device, "change")
for device, _placement in changed:
for device, _placement, _width, _horizontal_position in changed:
if not user.has_perm(permission, obj=device):
raise PermissionDenied(f"Keine Berechtigung zum Verschieben von {device}.")
for device, _placement in changed:
for device, _placement, _width, _horizontal_position in changed:
device.snapshot()
changed_ids = [device.pk for device, _placement in changed]
changed_ids = [device.pk for device, _placement, _width, _horizontal_position in changed]
Device.objects.filter(pk__in=changed_ids).update(position=None, face="")
for device, placement in changed:
for device, placement, width, horizontal_position in changed:
device.position = placement.position
device.face = placement.face
stage_width_position(device, width, horizontal_position)
device.full_clean()
device.save()
return [device for device, _placement in changed]
if width == FULL_WIDTH or placement.position is None:
DeviceRackPlacement.objects.filter(device=device).delete()
else:
DeviceRackPlacement.objects.update_or_create(
device=device,
defaults={"width": width, "horizontal_position": horizontal_position},
)
return [device for device, _placement, _width, _horizontal_position in changed]
def _validation_error_message(error):
@@ -117,6 +198,119 @@ def _validation_error_message(error):
return "; ".join(str(message) for message in error.messages)
def is_reorder_rack_request(request):
resolver_match = getattr(request, "resolver_match", None)
return getattr(resolver_match, "view_name", None) == REORDER_RACK_VIEW
def reorder_rack_width_enabled(request):
if not (
is_reorder_rack_request(request)
and get_plugin_config("netbox_utilities", "reorder_rack_bulk_save_enabled")
and apps.is_installed("netbox_reorder_rack")
):
return False
plugin_config = apps.get_app_config("netbox_reorder_rack")
return getattr(plugin_config, "version", None) in SUPPORTED_REORDER_RACK_VERSIONS
def reorder_grid_y(rack, position, height):
rack_height = int(Decimal(str(rack.u_height)) * 2)
grid_height = int(Decimal(str(height)) * 2)
unit_id = int(Decimal(str(position)) * 2)
if rack.desc_units:
return unit_id - 2
if grid_height > 1:
return rack_height - unit_id - grid_height + 2
return rack_height - unit_id
def _image_url(image):
if not image:
return ""
try:
return image.url
except (AttributeError, ValueError):
return ""
def get_reorder_rack_width_data(request):
"""Describe partial-width widgets for netbox-reorder-rack's GridStack UI."""
if not reorder_rack_width_enabled(request):
return None
rack_id = getattr(request.resolver_match, "kwargs", {}).get("pk")
if not rack_id:
return None
from dcim.svg.racks import get_device_name
from netbox.config import get_config
from utilities.html import foreground_color
try:
rack = Rack.objects.restrict(request.user, "view").filter(pk=rack_id).first()
if rack is None:
return None
devices = (
Device.objects.restrict(request.user, "view")
.filter(
rack=rack,
position__isnull=False,
netbox_utilities_rack_placement__isnull=False,
)
.select_related("device_type", "role", "netbox_utilities_rack_placement")
.order_by("pk")
)
permission = get_permission_for_model(Device, "change")
descriptors = []
for device in devices:
placement = device.netbox_utilities_rack_placement
grid_width, grid_x = grid_dimensions(placement.width, placement.horizontal_position)
role_color = device.role.color or "1685fc"
grid_height = int(Decimal(str(device.device_type.u_height)) * 2)
descriptors.append(
{
"id": device.pk,
"label": get_device_name(device),
"face": device.face,
"full_depth": device.device_type.is_full_depth,
"grid_x": grid_x,
"grid_y": reorder_grid_y(rack, device.position, device.device_type.u_height),
"grid_width": grid_width,
"grid_height": grid_height,
"width": placement.width,
"horizontal_position": placement.horizontal_position,
"color": role_color,
"text_color": foreground_color(role_color),
"front_image": _image_url(device.device_type.front_image),
"rear_image": _image_url(device.device_type.rear_image),
"locked": not request.user.has_perm(permission, obj=device),
}
)
if not descriptors:
return None
selected_view = request.GET.get("view", "images-and-labels")
return {
"columns": GRID_COLUMNS,
"unit_width": get_config().RACK_ELEVATION_DEFAULT_UNIT_WIDTH,
"images": selected_view != "labels-only",
"labels": selected_view != "images-only",
"devices": descriptors,
}
except (
AttributeError,
ObjectDoesNotExist,
OperationalError,
ProgrammingError,
RackLayoutError,
TypeError,
ValueError,
):
logger.warning("Could not load partial rack widths for netbox-reorder-rack", exc_info=True)
return None
def bulk_reorder_update(self, request, pk=None):
serializer = self.serializer_class(data=request.data)
serializer.is_valid(raise_exception=True)
@@ -128,7 +322,12 @@ def bulk_reorder_update(self, request, pk=None):
if layout["rack_id"] != rack.pk:
raise RackLayoutError("Die Rack-ID der Anfrage stimmt nicht mit dem Ziel-Rack überein.")
placements = parse_device_placements(layout)
grid_columns_header = request.headers.get("X-NetBox-Utilities-Rack-Grid-Columns")
try:
grid_columns = int(grid_columns_header) if grid_columns_header is not None else None
except (TypeError, ValueError):
raise RackLayoutError("Das angegebene Rackraster ist ungültig.") from None
placements = parse_device_placements(layout, grid_columns=grid_columns)
changed_devices = apply_rack_layout(rack=rack, placements=placements, user=request.user)
if not changed_devices:
return Response(
@@ -29,6 +29,13 @@
white-space: nowrap;
}
.rack-device.netbox-utilities-partial-width {
box-sizing: border-box;
right: auto !important;
left: calc(var(--netbox-utilities-rack-device-left) + 3px) !important;
width: calc(var(--netbox-utilities-rack-device-width) - 6px) !important;
}
.netbox-utilities-sidebar-resizer {
display: none;
}
@@ -0,0 +1,136 @@
(() => {
'use strict';
const source = document.getElementById('netbox-utilities-reorder-rack-width-data');
if (!source) return;
let data;
try {
data = JSON.parse(source.textContent);
} catch (error) {
console.error('NetBox Utilities could not read the Reorder Rack width data.', error);
return;
}
const columns = Number.parseInt(data.columns, 10) || 12;
const gridElements = {
front: document.getElementById('grid-front'),
rear: document.getElementById('grid-rear'),
other: document.getElementById('grid-other'),
};
if (!gridElements.front || !gridElements.rear || !gridElements.other) return;
const setAttribute = (element, name, value) => element.setAttribute(name, String(value));
const partialDeviceIds = new Set(data.devices.map(device => String(device.id)));
const originalFetch = window.fetch.bind(window);
window.fetch = (resource, options = {}) => {
const resourceUrl = typeof resource === 'string' || resource instanceof URL
? resource
: resource?.url;
let url;
try {
url = new URL(resourceUrl, document.baseURI);
} catch (_) {
return originalFetch(resource, options);
}
const method = String(options.method || resource?.method || 'GET').toUpperCase();
if (method !== 'PUT' || !/\/api\/plugins\/reorder\/save\/\d+\/?$/.test(url.pathname)) {
return originalFetch(resource, options);
}
const headers = new Headers(options.headers || resource?.headers);
headers.set('X-NetBox-Utilities-Rack-Grid-Columns', String(columns));
return originalFetch(resource, {...options, headers});
};
Object.values(gridElements).forEach(grid => {
setAttribute(grid, 'gs-column', columns);
grid.querySelectorAll('.grid-stack-item').forEach(item => {
if (partialDeviceIds.has(item.getAttribute('gs-id'))) {
item.remove();
return;
}
setAttribute(item, 'gs-w', columns);
setAttribute(item, 'gs-x', 0);
item.dataset.rackWidth = '1';
item.dataset.horizontalPosition = '1';
});
});
const addWidget = (device, gridFace, itemFace, rearSide = false) => {
const item = document.createElement('div');
item.className = 'grid-stack-item netbox-utilities-reorder-rack-device';
setAttribute(item, 'gs-w', device.grid_width);
setAttribute(item, 'gs-h', device.grid_height);
setAttribute(item, 'gs-x', device.grid_x);
setAttribute(item, 'gs-y', device.grid_y);
setAttribute(item, 'gs-id', device.id);
setAttribute(item, 'gs-locked', device.locked ? 'true' : 'false');
setAttribute(item, 'gs-no-move', device.locked ? 'true' : 'false');
item.dataset.itemColor = device.color;
item.dataset.itemTextColor = device.text_color;
item.dataset.fullDepth = device.full_depth ? 'True' : 'False';
item.dataset.itemFace = itemFace;
item.dataset.rackWidth = String(device.width);
item.dataset.horizontalPosition = String(device.horizontal_position);
const content = document.createElement('div');
content.className = 'grid-stack-item-content';
if (rearSide) content.classList.add('device_rear');
const image = rearSide ? device.rear_image : device.front_image;
if (image && data.images) {
content.style.backgroundImage = `url("${String(image).replaceAll('"', '\\"')}")`;
content.style.backgroundSize = `${data.unit_width}px`;
content.style.color = `#${device.text_color}`;
if (data.labels) content.textContent = device.label;
} else {
if (!rearSide) content.style.backgroundColor = `#${device.color}`;
content.style.color = rearSide ? '#000000' : `#${device.text_color}`;
content.textContent = device.label;
}
item.appendChild(content);
gridElements[gridFace].appendChild(item);
};
data.devices.forEach(device => {
const face = device.face === 'rear' ? 'rear' : 'front';
addWidget(device, face, face);
if (device.full_depth) {
addWidget(device, face === 'front' ? 'rear' : 'front', 'back', true);
}
});
const snapElement = element => {
if (!element?.gridstackNode) return;
const width = Number.parseInt(element.dataset.rackWidth || '1', 10);
if (![1, 2, 3, 4].includes(width)) return;
const gridWidth = columns / width;
const node = element.gridstackNode;
const gridX = Math.max(0, Math.min(columns - gridWidth, Math.round(node.x / gridWidth) * gridWidth));
element.dataset.horizontalPosition = String(gridX / gridWidth + 1);
if (node.x !== gridX || node.w !== gridWidth) {
node.grid.update(element, {x: gridX, w: gridWidth});
}
};
const snapGrid = grid => grid.getGridItems().forEach(snapElement);
const attachSnapping = () => {
Object.values(gridElements).forEach(element => {
const grid = element.gridstack;
if (!grid) return;
grid.on('dragstop', (_event, item) => snapElement(item));
grid.on('dropped', (_event, _previous, current) => snapElement(current?.el));
});
};
document.addEventListener('click', event => {
const target = event.target instanceof Element ? event.target : event.target?.parentElement;
if (!target?.closest('#saveButton')) return;
Object.values(gridElements).forEach(element => {
if (element.gridstack) snapGrid(element.gridstack);
});
}, true);
document.addEventListener('DOMContentLoaded', attachSnapping, {once: true});
})();
@@ -0,0 +1,371 @@
(() => {
'use strict';
const placementSource = document.getElementById('netbox-utilities-topology-rack-width-data');
if (!placementSource) return;
let placementData;
try {
placementData = JSON.parse(placementSource.textContent);
} catch (error) {
console.error('NetBox Utilities could not read the Topology Views rack widths.', error);
return;
}
const normalizePath = value => {
try {
return new URL(value, document.baseURI).pathname.replace(/\/+$/, '/');
} catch (_) {
return '';
}
};
const normalizePlacement = value => {
const width = Number.parseInt(value?.width, 10);
const horizontalPosition = Number.parseInt(value?.horizontal_position, 10);
if (![2, 3, 4].includes(width) || horizontalPosition < 1 || horizontalPosition > width) return null;
return {
width,
horizontalPosition,
leftPercent: Number.isFinite(Number(value.left_percent))
? Number(value.left_percent)
: (horizontalPosition - 1) / width * 100,
widthPercent: Number.isFinite(Number(value.width_percent))
? Number(value.width_percent)
: 100 / width,
};
};
const placementsByPath = new Map();
const placementsByDeviceId = new Map();
placementData.forEach(item => {
const placement = normalizePlacement(item);
if (!placement) return;
const path = normalizePath(item.url);
if (path) placementsByPath.set(path, placement);
placementsByDeviceId.set(String(item.device_id), placement);
});
const rackCards = Array.from(document.querySelectorAll('.rack-card[data-rack-id][data-rack-units]'));
const liveViewCompatible = rackCards.length > 0 && rackCards.every(card => {
if (!/^\d+$/.test(String(card.dataset.rackId)) || !Number.isFinite(Number(card.dataset.rackUnits))) {
return false;
}
return Array.from(card.querySelectorAll('.rack-device')).every(device => (
device.hasAttribute('href')
&& ['front', 'rear'].includes(device.dataset.face)
&& Number.isFinite(Number(device.dataset.position))
&& Number.isFinite(Number(device.dataset.deviceHeight))
&& typeof device.dataset.deviceColor === 'string'
));
});
if (!liveViewCompatible) {
console.warn('NetBox Utilities left Topology Views unchanged because its rack DOM is not compatible.');
return;
}
const placementForElement = element => {
const path = normalizePath(element.getAttribute('href'));
if (placementsByPath.has(path)) return placementsByPath.get(path);
const deviceId = path.match(/\/devices\/(\d+)\/?$/)?.[1];
return deviceId ? placementsByDeviceId.get(deviceId) : null;
};
document.querySelectorAll('.rack-device').forEach(device => {
const placement = placementForElement(device);
if (!placement) return;
device.classList.add('netbox-utilities-partial-width');
device.dataset.rackWidth = String(placement.width);
device.dataset.horizontalPosition = String(placement.horizontalPosition);
device.style.setProperty('--netbox-utilities-rack-device-left', `${placement.leftPercent}%`);
device.style.setProperty('--netbox-utilities-rack-device-width', `${placement.widthPercent}%`);
});
const rackDataSource = document.getElementById('rack-export-data');
if (!rackDataSource) return;
let rackData;
try {
rackData = JSON.parse(rackDataSource.textContent);
} catch (error) {
console.error('NetBox Utilities could not read the Topology Views rack export data.', error);
return;
}
const rackExportCompatible = Array.isArray(rackData) && rackData.length > 0 && rackData.every(rack => {
if (!rack || !/^\d+$/.test(String(rack.id)) || !Array.isArray(rack.devices)) return false;
if (!Number.isFinite(Number(rack.u_height)) || !Number.isFinite(Number(rack.starting_unit))) return false;
const card = document.querySelector(`.rack-card[data-rack-id="${rack.id}"]`);
if (!card || !Number.isFinite(Number(card.dataset.rackUnits))) return false;
return true;
});
if (!rackExportCompatible) {
console.warn('NetBox Utilities left Topology Views exports unchanged because their schema is not compatible.');
return;
}
const xmlEscape = value => String(value ?? '').replace(/[&<>"']/g, character => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&apos;',
})[character]);
const safeName = value => String(value || 'export')
.replace(/[^a-z0-9._-]+/gi, '-')
.replace(/^-|-$/g, '');
const numberOr = (value, fallback) => {
const number = Number.parseFloat(value);
return Number.isFinite(number) ? number : fallback;
};
const activeNetBoxTheme = () => {
for (const element of [document.body, document.documentElement]) {
const match = getComputedStyle(element).backgroundColor.match(/[\d.]+/g);
if (match && match.length >= 3 && (match.length < 4 || Number(match[3]) > 0)) {
const [red, green, blue] = match.map(Number);
return (.2126 * red + .7152 * green + .0722 * blue) < 140 ? 'dark' : 'light';
}
}
return document.body.dataset.bsTheme || document.documentElement.dataset.bsTheme || 'light';
};
const exportPalette = () => activeNetBoxTheme() === 'dark' ? {
frame: '#52606d',
grid: '#667382',
heading: '#8fc8ff',
muted: '#a9b7c6',
deviceFill: '#162e45',
deviceText: '#ffffff',
deviceSubtext: '#cad5df',
} : {
frame: '#718096',
grid: '#8b99a8',
heading: '#0b69b7',
muted: '#52606d',
deviceFill: '#eef6ff',
deviceText: '#132238',
deviceSubtext: '#52606d',
};
const exportPlacement = device => {
const width = Number.parseInt(device.dataset.rackWidth || '1', 10);
const horizontalPosition = Number.parseInt(device.dataset.horizontalPosition || '1', 10);
if (![1, 2, 3, 4].includes(width) || horizontalPosition < 1 || horizontalPosition > width) {
return {width: 1, horizontalPosition: 1};
}
return {width, horizontalPosition};
};
const renderedRackData = racks => racks.map(rack => {
const card = document.querySelector(`.rack-card[data-rack-id="${rack.id}"]`);
if (!card) return rack;
const rackUnits = numberOr(card.dataset.rackUnits, numberOr(rack.u_height, 1));
const devices = Array.from(card.querySelectorAll('.rack-device')).map(device => {
const placement = exportPlacement(device);
return {
name: device.dataset.deviceName || device.querySelector('span')?.textContent || 'Gerät',
type: device.dataset.deviceType || '',
position: numberOr(device.dataset.position, numberOr(rack.starting_unit, 1)),
height: Math.max(numberOr(device.dataset.deviceHeight, 100 / rackUnits), 1),
face: device.dataset.face === 'rear' ? 'rear' : 'front',
color: device.dataset.deviceColor || '#1685fc',
width: placement.width,
horizontalPosition: placement.horizontalPosition,
};
});
return {...rack, devices};
});
const horizontalGeometry = (faceX, faceWidth, device) => {
const width = [1, 2, 3, 4].includes(device.width) ? device.width : 1;
const horizontalPosition = device.horizontalPosition >= 1 && device.horizontalPosition <= width
? device.horizontalPosition
: 1;
const slotWidth = faceWidth / width;
return {
x: faceX + (horizontalPosition - 1) * slotWidth + 3,
width: Math.max(slotWidth - 6, 1),
};
};
const svgDocument = (width, height, content) =>
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">${content}</svg>`;
const drawioDocument = cells =>
`<mxfile host="app.diagrams.net" type="device"><diagram name="Rack export"><mxGraphModel page="0" background="none" grid="1" guides="1" connect="1"><root><mxCell id="0"/><mxCell id="1" parent="0"/>${cells}</root></mxGraphModel></diagram></mxfile>`;
const buildRackDrawio = racks => {
const palette = exportPalette();
const unit = 22;
const faceWidth = 250;
const labelWidth = 28;
const faceGap = 28;
const rackGap = 44;
const header = 55;
const rackWidth = labelWidth + faceWidth * 2 + faceGap;
let cells = '';
let cellId = 2;
const vertex = (value, style, x, y, width, height) => {
cells += `<mxCell id="${cellId++}" value="${xmlEscape(value)}" style="${style}" vertex="1" parent="1"><mxGeometry x="${x}" y="${y}" width="${width}" height="${height}" as="geometry"/></mxCell>`;
};
racks.forEach((rack, rackIndex) => {
const rackUnits = numberOr(rack.u_height, 1);
const baseX = 10 + rackIndex * (rackWidth + rackGap);
const rackHeight = rackUnits * unit;
const startingUnit = numberOr(rack.starting_unit, 1);
vertex(rack.name, `text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;fontSize=16;fontStyle=1;fontColor=${palette.heading};`, baseX, 0, rackWidth, 25);
vertex(`${rack.site}${rack.location ? ` · ${rack.location}` : ''} · ${rackUnits}U`, `text;html=1;strokeColor=none;fillColor=none;align=left;fontSize=10;fontColor=${palette.muted};`, baseX, 24, rackWidth, 20);
['front', 'rear'].forEach((face, faceIndex) => {
const faceX = baseX + labelWidth + faceIndex * (faceWidth + faceGap);
vertex(face === 'front' ? 'VORDERSEITE' : 'RÜCKSEITE', `text;html=1;strokeColor=none;fillColor=none;align=center;fontSize=10;fontColor=${palette.muted};`, faceX, 38, faceWidth, 17);
vertex('', `rounded=0;html=1;fillColor=none;strokeColor=${palette.frame};strokeWidth=4;`, faceX, header, faceWidth, rackHeight);
for (let row = 0; row < rackUnits; row += 1) {
const y = header + row * unit;
const unitLabel = rack.desc_units ? startingUnit + row : startingUnit + rackUnits - 1 - row;
if (faceIndex === 0) {
vertex(String(unitLabel), `text;html=1;strokeColor=none;fillColor=none;align=right;fontSize=8;fontColor=${palette.muted};`, faceX - labelWidth, y, labelWidth - 4, unit);
}
}
rack.devices.filter(device => device.face === face).forEach(device => {
const positionOffset = device.position - startingUnit;
const y = rack.desc_units
? header + positionOffset * unit
: header + rackHeight - (positionOffset + device.height) * unit;
const height = Math.max(device.height * unit, 4);
const geometry = horizontalGeometry(faceX, faceWidth, device);
const value = height >= 31
? `${device.name}<br><font style="font-size:9px">U${device.position} · ${device.type}</font>`
: device.name;
vertex(value, `rounded=1;html=1;whiteSpace=wrap;overflow=hidden;align=left;verticalAlign=top;spacingLeft=5;spacingTop=2;fillColor=${palette.deviceFill};strokeColor=${device.color};strokeWidth=2;fontColor=${palette.deviceText};fontSize=11;`, geometry.x, y + 1, geometry.width, height - 2);
});
});
});
return drawioDocument(cells);
};
const buildRackSvg = racks => {
const palette = exportPalette();
const unit = 22;
const faceWidth = 250;
const labelWidth = 28;
const faceGap = 28;
const rackGap = 44;
const header = 55;
const rackWidth = labelWidth + faceWidth * 2 + faceGap;
const maxUnits = Math.max(...racks.map(rack => numberOr(rack.u_height, 1)), 1);
const width = racks.length * rackWidth + Math.max(0, racks.length - 1) * rackGap + 20;
const height = header + maxUnits * unit + 25;
let definitions = '';
let content = '';
racks.forEach((rack, rackIndex) => {
const rackUnits = numberOr(rack.u_height, 1);
const baseX = 10 + rackIndex * (rackWidth + rackGap);
const rackHeight = rackUnits * unit;
const startingUnit = numberOr(rack.starting_unit, 1);
content += `<text x="${baseX}" y="20" fill="${palette.heading}" font-family="sans-serif" font-size="16" font-weight="700">${xmlEscape(rack.name)}</text>`;
content += `<text x="${baseX}" y="39" fill="${palette.muted}" font-family="sans-serif" font-size="11">${xmlEscape(rack.site)}${rack.location ? ` · ${xmlEscape(rack.location)}` : ''} · ${rackUnits}U</text>`;
['front', 'rear'].forEach((face, faceIndex) => {
const faceX = baseX + labelWidth + faceIndex * (faceWidth + faceGap);
content += `<text x="${faceX + faceWidth / 2}" y="51" text-anchor="middle" fill="${palette.muted}" font-family="sans-serif" font-size="10">${face === 'front' ? 'VORDERSEITE' : 'RÜCKSEITE'}</text>`;
content += `<rect x="${faceX}" y="${header}" width="${faceWidth}" height="${rackHeight}" fill="none" stroke="${palette.frame}" stroke-width="4"/>`;
for (let row = 0; row < rackUnits; row += 1) {
const y = header + row * unit;
const unitLabel = rack.desc_units ? startingUnit + row : startingUnit + rackUnits - 1 - row;
content += `<line x1="${faceX}" y1="${y}" x2="${faceX + faceWidth}" y2="${y}" stroke="${palette.grid}" stroke-opacity=".35"/>`;
if (faceIndex === 0) {
content += `<text x="${faceX - 6}" y="${y + 15}" text-anchor="end" fill="${palette.muted}" font-family="sans-serif" font-size="9">${unitLabel}</text>`;
}
}
rack.devices.filter(device => device.face === face).forEach((device, deviceIndex) => {
const positionOffset = device.position - startingUnit;
const y = rack.desc_units
? header + positionOffset * unit
: header + rackHeight - (positionOffset + device.height) * unit;
const deviceHeight = Math.max(device.height * unit, 4);
const geometry = horizontalGeometry(faceX, faceWidth, device);
const clipId = `netbox-utilities-rack-${rackIndex}-${faceIndex}-${deviceIndex}`;
definitions += `<clipPath id="${clipId}"><rect x="${geometry.x}" y="${y + 1}" width="${geometry.width}" height="${deviceHeight - 2}"/></clipPath>`;
content += `<g clip-path="url(#${clipId})">`;
content += `<rect x="${geometry.x}" y="${y + 1}" width="${geometry.width}" height="${deviceHeight - 2}" rx="3" fill="${palette.deviceFill}" stroke="${xmlEscape(device.color)}" stroke-width="2"/>`;
if (deviceHeight >= 13) {
content += `<text x="${geometry.x + 6}" y="${y + Math.min(15, deviceHeight - 4)}" fill="${palette.deviceText}" font-family="sans-serif" font-size="11" font-weight="600">${xmlEscape(device.name)}</text>`;
}
if (deviceHeight >= 31) {
content += `<text x="${geometry.x + 6}" y="${y + 28}" fill="${palette.deviceSubtext}" font-family="sans-serif" font-size="9">U${device.position} · ${xmlEscape(device.type)}</text>`;
}
content += '</g>';
});
});
});
const svg = svgDocument(width, height, `<defs>${definitions}</defs>${content}`);
return {svg, drawio: buildRackDrawio(racks), width, height};
};
const downloadBlob = (blob, filename) => {
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = filename;
link.click();
setTimeout(() => URL.revokeObjectURL(link.href), 1000);
};
const exportGraphic = (graphic, format, filename) => {
if (format === 'svg') {
downloadBlob(new Blob([graphic.svg], {type: 'image/svg+xml'}), `${filename}.svg`);
return;
}
if (format === 'drawio') {
downloadBlob(new Blob([graphic.drawio], {type: 'application/xml'}), `${filename}.drawio`);
return;
}
const image = new Image();
const url = URL.createObjectURL(new Blob([graphic.svg], {type: 'image/svg+xml'}));
image.onload = () => {
const scale = 2;
const canvas = document.createElement('canvas');
canvas.width = graphic.width * scale;
canvas.height = graphic.height * scale;
const context = canvas.getContext('2d');
context.clearRect(0, 0, canvas.width, canvas.height);
context.scale(scale, scale);
context.drawImage(image, 0, 0);
canvas.toBlob(blob => downloadBlob(blob, `${filename}.png`), 'image/png');
URL.revokeObjectURL(url);
};
image.onerror = () => URL.revokeObjectURL(url);
image.src = url;
};
const selectedRacks = button => button.dataset.scope === 'all'
? rackData
: rackData.filter(rack => String(rack.id) === button.dataset.scope);
const containsPartialWidthDevice = racks => racks.some(rack =>
document.querySelector(`.rack-card[data-rack-id="${rack.id}"] .netbox-utilities-partial-width`));
document.addEventListener('click', event => {
const target = event.target instanceof Element ? event.target : event.target?.parentElement;
const button = target?.closest('.rack-export');
if (!button) return;
const selection = selectedRacks(button);
if (!selection.length || !containsPartialWidthDevice(selection)) return;
try {
const racks = renderedRackData(selection);
const graphic = buildRackSvg(racks);
const filename = button.dataset.scope === 'all'
? 'racks-gesamt'
: `rack-${safeName(racks[0]?.name)}`;
exportGraphic(graphic, button.dataset.format, filename);
event.preventDefault();
event.stopImmediatePropagation();
} catch (error) {
// Let Topology Views' original click handler provide its normal export fallback.
console.error('NetBox Utilities could not create the width-aware rack export.', error);
}
}, true);
})();
+6
View File
@@ -14,12 +14,16 @@ from .navigation_helpers import (
normalize_preferences,
normalize_sidebar_width,
)
from .reorder_rack import get_reorder_rack_width_data
from .runtime import navigation_customization_enabled, tenant_filter_enabled
from .topology_views import get_topology_rack_width_data
class UtilitiesGlobalContent(PluginTemplateExtension):
def head(self):
request = self.context["request"]
reorder_rack_width_data = get_reorder_rack_width_data(request)
topology_rack_width_data = get_topology_rack_width_data(request)
preference_data = {
"order": [],
"hidden": [],
@@ -56,6 +60,8 @@ class UtilitiesGlobalContent(PluginTemplateExtension):
},
"navigation_enabled": request.user.is_authenticated and navigation_customization_enabled(),
"navigation_preferences": preference_data,
"reorder_rack_width_data": reorder_rack_width_data,
"topology_rack_width_data": topology_rack_width_data,
"asset_version": __version__,
},
)
@@ -8,3 +8,11 @@
{{ navigation_preferences|json_script:"netbox-utilities-navigation-data" }}
<script src="{% static 'netbox_utilities/navigation.js' %}?v={{ asset_version }}" defer></script>
{% endif %}
{% if topology_rack_width_data %}
{{ 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 %}
{% if reorder_rack_width_data %}
{{ reorder_rack_width_data|json_script:"netbox-utilities-reorder-rack-width-data" }}
<script src="{% static 'netbox_utilities/reorder-rack-width.js' %}?v={{ asset_version }}" defer></script>
{% endif %}
+50 -7
View File
@@ -74,14 +74,15 @@ class RackWidthTest(SimpleTestCase):
def test_device_edit_view_uses_explicit_rack_width_form(self):
from netbox.registry import registry
edit_view = next(view["view"] for view in registry["views"]["dcim"]["device"] if view["name"] == "edit")
for action in ("add", "edit"):
device_view = next(view["view"] for view in registry["views"]["dcim"]["device"] if view["name"] == action)
self.assertEqual(edit_view.__name__, "RackWidthDeviceEditView")
self.assertEqual(edit_view.form.__name__, "RackWidthDeviceForm")
self.assertEqual(edit_view.template_name, "netbox_utilities/device_edit.html")
self.assertEqual(edit_view.htmx_template_name, "netbox_utilities/device_edit_form.html")
self.assertIn("utilities_rack_width", edit_view.form.base_fields)
self.assertIn("utilities_horizontal_position", edit_view.form.base_fields)
self.assertEqual(device_view.__name__, "RackWidthDeviceEditView")
self.assertEqual(device_view.form.__name__, "RackWidthDeviceForm")
self.assertEqual(device_view.template_name, "netbox_utilities/device_edit.html")
self.assertEqual(device_view.htmx_template_name, "netbox_utilities/device_edit_form.html")
self.assertIn("utilities_rack_width", device_view.form.base_fields)
self.assertIn("utilities_horizontal_position", device_view.form.base_fields)
def test_resolved_device_edit_page_renders_rack_width_fields(self):
from dcim.models import Device
@@ -133,6 +134,48 @@ class RackWidthTest(SimpleTestCase):
self.assertLess(html.index(width), html.index(horizontal_position))
self.assertLess(html.index(horizontal_position), html.index(latitude))
def test_resolved_device_add_page_renders_rack_width_fields(self):
from dcim.models import Device
object_type = SimpleNamespace(pk=1, model_class=lambda: Device)
with (
patch("core.models.ObjectType.objects.get_for_model", return_value=object_type),
patch(
"django.contrib.contenttypes.models.ContentType.objects.get_for_model",
return_value=object_type,
),
patch("extras.models.CustomField.objects.get_for_model", return_value=[]),
patch(
"netbox_utilities.runtime._get_database_settings",
return_value={"tenant_required": True},
),
):
view_class = resolve(reverse("dcim:device_add")).func.view_class
form = view_class.form()
request = self._anonymous_request()
context = {
"form": form,
"object": SimpleNamespace(
pk=None,
_meta=Device._meta,
device_type=SimpleNamespace(is_child_device=False),
parent_bay=None,
),
"return_url": "/dcim/devices/",
"request": request,
"settings": settings,
"csrf_token": "",
"perms": PermWrapper(request.user),
}
rendered_pages = (
render_to_string(view_class.htmx_template_name, context),
render_to_string(view_class.template_name, context),
)
for html in rendered_pages:
self.assertIn('name="utilities_rack_width"', html)
self.assertIn('name="utilities_horizontal_position"', html)
def test_bound_device_form_clean_handles_netbox_clean_returning_none(self):
from dcim.models import Device
from netbox.registry import registry
+189 -7
View File
@@ -1,16 +1,21 @@
from decimal import Decimal
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from django.test import SimpleTestCase
from django.test import RequestFactory, SimpleTestCase
from rest_framework.exceptions import PermissionDenied
from netbox_utilities.reorder_rack import (
RackLayoutError,
apply_rack_layout,
bulk_reorder_update,
get_reorder_rack_width_data,
grid_dimensions,
horizontal_position_from_grid_x,
install_reorder_rack_bulk_save,
parse_device_placements,
reorder_grid_y,
)
@@ -32,14 +37,41 @@ class RackLayoutParsingTest(SimpleTestCase):
)
self.assertEqual(
[(item.device_id, item.position, item.face) for item in placements],
[(item.device_id, item.position, item.face, item.grid_x) for item in placements],
[
(10, Decimal(4), "front"),
(11, Decimal("8.5"), "front"),
(12, None, ""),
(10, Decimal(4), "front", None),
(11, Decimal("8.5"), "front", None),
(12, None, "", None),
],
)
def test_reads_horizontal_positions_only_from_marked_twelve_column_payload(self):
placements = parse_device_placements(
rack_layout(front=[{"id": 10, "x": 6, "y": 4}]),
grid_columns=12,
)
self.assertEqual(placements[0].grid_x, 6)
def test_maps_rack_widths_to_twelve_column_grid(self):
self.assertEqual(grid_dimensions(1, 1), (12, 0))
self.assertEqual(grid_dimensions(2, 2), (6, 6))
self.assertEqual(grid_dimensions(3, 2), (4, 4))
self.assertEqual(grid_dimensions(4, 4), (3, 9))
def test_maps_grid_x_back_to_horizontal_position(self):
self.assertEqual(horizontal_position_from_grid_x(2, 6), 2)
self.assertEqual(horizontal_position_from_grid_x(3, 8), 3)
self.assertEqual(horizontal_position_from_grid_x(4, 9), 4)
def test_rejects_misaligned_grid_position(self):
with self.assertRaisesMessage(RackLayoutError, "nicht zulässig"):
horizontal_position_from_grid_x(2, 3)
def test_rejects_non_integer_grid_position(self):
with self.assertRaisesMessage(RackLayoutError, "ganze Zahl"):
horizontal_position_from_grid_x(2, 6.0)
def test_rejects_duplicate_devices(self):
with self.assertRaisesMessage(RackLayoutError, "mehrfach"):
parse_device_placements(
@@ -55,9 +87,15 @@ class RackLayoutParsingTest(SimpleTestCase):
class ApplyRackLayoutTest(SimpleTestCase):
@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_clears_all_old_positions_before_saving_targets(self, device_model, _get_permission):
def test_clears_all_old_positions_before_saving_targets(
self,
device_model,
_get_permission,
placement_model,
):
events = []
first = MagicMock(pk=10, position=Decimal(1), face="front")
second = MagicMock(pk=11, position=Decimal(2), face="front")
@@ -70,6 +108,7 @@ class ApplyRackLayoutTest(SimpleTestCase):
device_model.objects.select_for_update.return_value.filter.return_value = [first, second]
device_model.objects.filter.return_value.update.side_effect = lambda **_kwargs: events.append("clear")
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, "y": 2}, {"id": 11, "y": 1}]))
@@ -83,11 +122,13 @@ class ApplyRackLayoutTest(SimpleTestCase):
self.assertEqual((first.position, first.face), (Decimal(2), "front"))
self.assertEqual((second.position, second.face), (Decimal(1), "front"))
@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_rejects_changed_device_without_permission(self, device_model, _get_permission):
def test_rejects_changed_device_without_permission(self, device_model, _get_permission, placement_model):
device = MagicMock(pk=10, position=Decimal(1), face="front")
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()
user.has_perm.return_value = False
placements = parse_device_placements(rack_layout(front=[{"id": 10, "y": 2}]))
@@ -98,6 +139,147 @@ class ApplyRackLayoutTest(SimpleTestCase):
device_model.objects.filter.assert_not_called()
device.save.assert_not_called()
@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_saves_horizontal_only_move_for_partial_width_device(
self,
device_model,
_get_permission,
placement_model,
):
device = MagicMock(pk=10, position=Decimal(4), face="front")
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]
user = MagicMock()
user.has_perm.return_value = True
placements = parse_device_placements(
rack_layout(front=[{"id": 10, "x": 6, "y": 4}]),
grid_columns=12,
)
changed = apply_rack_layout(rack=SimpleNamespace(pk=5), placements=placements, user=user)
self.assertEqual(changed, [device])
self.assertEqual(device._netbox_utilities_rack_width, 2)
self.assertEqual(device._netbox_utilities_horizontal_position, 2)
placement_model.objects.update_or_create.assert_called_once_with(
device=device,
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_legacy_payload_preserves_stored_horizontal_position(
self,
device_model,
_get_permission,
placement_model,
):
device = MagicMock(pk=10, position=Decimal(4), face="front")
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]
user = MagicMock()
user.has_perm.return_value = True
placements = parse_device_placements(rack_layout(front=[{"id": 10, "x": 0, "y": 5}]))
changed = apply_rack_layout(rack=SimpleNamespace(pk=5), placements=placements, user=user)
self.assertEqual(changed, [device])
placement_model.objects.update_or_create.assert_called_once_with(
device=device,
defaults={"width": 2, "horizontal_position": 2},
)
class ReorderRackFrontendTest(SimpleTestCase):
def test_grid_y_matches_reorder_plugin_for_both_numbering_directions(self):
self.assertEqual(reorder_grid_y(SimpleNamespace(u_height=42, desc_units=True), 4, 2), 6)
self.assertEqual(reorder_grid_y(SimpleNamespace(u_height=42, desc_units=False), 4, 2), 74)
def test_width_adapter_runs_before_gridstack_and_preserves_original_save(self):
script = (Path(__file__).parents[1] / "static" / "netbox_utilities" / "reorder-rack-width.js").read_text(
encoding="utf-8"
)
self.assertIn("setAttribute(grid, 'gs-column', columns)", script)
self.assertIn("setAttribute(item, 'gs-w', device.grid_width)", script)
self.assertIn("setAttribute(item, 'gs-x', device.grid_x)", script)
self.assertIn("grid.on('dragstop'", script)
self.assertIn("grid.on('dropped'", script)
self.assertIn("closest('#saveButton')", script)
self.assertIn("X-NetBox-Utilities-Rack-Grid-Columns", script)
self.assertNotIn("JSON.stringify", script)
@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):
request = RequestFactory().get("/dcim/racks/5/reorder/")
request.resolver_match = SimpleNamespace(kwargs={"pk": 5}, view_name="dcim:rack_reorder")
request.user = MagicMock()
rack_model.objects.restrict.return_value.filter.return_value.first.return_value = SimpleNamespace(pk=5)
with patch("netbox_utilities.reorder_rack.Device.objects.restrict", side_effect=AttributeError("stale")):
self.assertIsNone(get_reorder_rack_width_data(request))
@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")
@patch("netbox_utilities.reorder_rack.Device")
@patch("netbox_utilities.reorder_rack.Rack")
def test_describes_partial_devices_for_reorder_grid(
self,
rack_model,
device_model,
_get_permission,
_enabled,
):
request = RequestFactory().get("/dcim/racks/5/reorder/?view=labels-only")
request.resolver_match = SimpleNamespace(kwargs={"pk": 5}, view_name="dcim:rack_reorder")
request.user = MagicMock()
request.user.has_perm.return_value = True
rack = SimpleNamespace(pk=5, u_height=42, desc_units=False)
rack_model.objects.restrict.return_value.filter.return_value.first.return_value = rack
placement = SimpleNamespace(width=2, horizontal_position=2)
device = SimpleNamespace(
pk=10,
name="Fritzbox",
virtual_chassis=None,
device_type=SimpleNamespace(
u_height=1,
is_full_depth=False,
front_image=None,
rear_image=None,
),
role=SimpleNamespace(color="abcdef"),
face="front",
position=Decimal(4),
netbox_utilities_rack_placement=placement,
)
device_queryset = MagicMock()
device_queryset.filter.return_value.select_related.return_value.order_by.return_value = [device]
device_model.objects.restrict.return_value = device_queryset
with (
patch("dcim.svg.racks.get_device_name", return_value="Fritzbox"),
patch(
"netbox.config.get_config",
return_value=SimpleNamespace(RACK_ELEVATION_DEFAULT_UNIT_WIDTH=220),
),
patch("utilities.html.foreground_color", return_value="000000"),
):
result = get_reorder_rack_width_data(request)
self.assertEqual(result["columns"], 12)
self.assertFalse(result["images"])
self.assertTrue(result["labels"])
self.assertEqual(
{key: result["devices"][0][key] for key in ("id", "grid_x", "grid_width", "width", "horizontal_position")},
{"id": 10, "grid_x": 6, "grid_width": 6, "width": 2, "horizontal_position": 2},
)
class ReorderRackPatchTest(SimpleTestCase):
def test_patches_supported_plugin_only_once(self):
@@ -0,0 +1,131 @@
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from django.test import RequestFactory, SimpleTestCase
from netbox_utilities.topology_views import (
get_topology_rack_width_data,
is_topology_rack_elevation_request,
serialize_topology_placement,
topology_rack_width_enabled,
)
class TopologyViewsRackWidthTest(SimpleTestCase):
def setUp(self):
self.request = RequestFactory().get("/plugins/netbox_topology_views/rack-elevation/?rack_id=3")
self.request.user = MagicMock()
def test_detects_only_topology_rack_elevation_route(self):
self.request.resolver_match = SimpleNamespace(view_name="plugins:netbox_topology_views:rack_elevation")
self.assertTrue(is_topology_rack_elevation_request(self.request))
self.request.resolver_match = SimpleNamespace(view_name="dcim:rack_list")
self.assertFalse(is_topology_rack_elevation_request(self.request))
def test_skips_integration_when_optional_plugin_is_missing(self):
self.request.resolver_match = SimpleNamespace(view_name="plugins:netbox_topology_views:rack_elevation")
with (
patch("netbox_utilities.topology_views.apps.is_installed", return_value=False),
patch("netbox_utilities.topology_views.get_plugin_config", return_value=True),
):
self.assertFalse(topology_rack_width_enabled(self.request))
def test_skips_integration_when_admin_disabled_it(self):
self.request.resolver_match = SimpleNamespace(view_name="plugins:netbox_topology_views:rack_elevation")
with (
patch("netbox_utilities.topology_views.apps.is_installed", return_value=True),
patch("netbox_utilities.topology_views.get_plugin_config", return_value=False),
):
self.assertFalse(topology_rack_width_enabled(self.request))
def test_serializes_fractional_width_geometry_and_device_url(self):
placement = SimpleNamespace(
device_id=17,
device=SimpleNamespace(get_absolute_url=lambda: "/dcim/devices/17/"),
width=3,
horizontal_position=2,
)
self.assertEqual(
serialize_topology_placement(placement),
{
"device_id": 17,
"url": "/dcim/devices/17/",
"width": 3,
"horizontal_position": 2,
"left_percent": 33.33333333,
"width_percent": 33.33333333,
},
)
@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):
self.request.resolver_match = SimpleNamespace(view_name="plugins:netbox_topology_views:rack_elevation")
rack_queryset = MagicMock()
rack_queryset.filter.return_value = rack_queryset
rack_queryset.values.return_value = "selected-rack-query"
rack_model = MagicMock()
rack_model.objects.restrict.return_value = rack_queryset
device_queryset = MagicMock()
device_queryset.filter.return_value = "permitted-device-query"
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
with patch.dict("sys.modules", {"dcim.models": SimpleNamespace(Device=device_model, Rack=rack_model)}):
result = get_topology_rack_width_data(self.request)
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,
)
placement_objects.filter.assert_called_once_with(device__in="permitted-device-query")
self.assertEqual(result[0]["width"], 2)
self.assertEqual(result[0]["horizontal_position"], 2)
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(
encoding="utf-8"
)
self.assertIn("netbox-utilities-partial-width", script)
self.assertIn("horizontalGeometry", script)
self.assertIn("buildRackSvg", script)
self.assertIn("buildRackDrawio", script)
self.assertIn("format === 'svg'", script)
self.assertIn("format === 'drawio'", script)
self.assertIn("canvas.toBlob", script)
self.assertIn("event.stopImmediatePropagation()", script)
self.assertIn("rackExportCompatible", script)
self.assertIn("liveViewCompatible", script)
self.assertLess(script.index("liveViewCompatible"), script.index("netbox-utilities-partial-width"))
self.assertLess(script.index("exportGraphic(graphic"), script.index("event.preventDefault()"))
@patch("netbox_utilities.topology_views.topology_rack_width_enabled", return_value=True)
def test_malformed_partial_placement_fails_open(self, _enabled):
self.request.resolver_match = SimpleNamespace(view_name="plugins:netbox_topology_views:rack_elevation")
with patch("dcim.models.Rack.objects.restrict", side_effect=AttributeError("stale")):
self.assertIsNone(get_topology_rack_width_data(self.request))
def test_partial_width_css_uses_topology_device_slot_geometry(self):
stylesheet = (Path(__file__).parents[1] / "static" / "netbox_utilities" / "netbox_utilities.css").read_text(
encoding="utf-8"
)
self.assertIn(".rack-device.netbox-utilities-partial-width", stylesheet)
self.assertIn("--netbox-utilities-rack-device-left", stylesheet)
self.assertIn("--netbox-utilities-rack-device-width", stylesheet)
+80
View File
@@ -0,0 +1,80 @@
import logging
from django.apps import apps
from django.core.exceptions import ObjectDoesNotExist
from django.db import OperationalError, ProgrammingError
from netbox.plugins import get_plugin_config
from .models import DeviceRackPlacement
logger = logging.getLogger(__name__)
TOPOLOGY_RACK_ELEVATION_VIEW = "plugins:netbox_topology_views:rack_elevation"
def is_topology_rack_elevation_request(request):
resolver_match = getattr(request, "resolver_match", None)
return getattr(resolver_match, "view_name", None) == TOPOLOGY_RACK_ELEVATION_VIEW
def topology_rack_width_enabled(request):
"""Return whether the optional Topology Views rack integration applies."""
return bool(
is_topology_rack_elevation_request(request)
and get_plugin_config("netbox_utilities", "topology_views_rack_width_enabled")
and apps.is_installed("netbox_topology_views")
)
def serialize_topology_placement(placement):
width = int(placement.width)
horizontal_position = int(placement.horizontal_position)
return {
"device_id": placement.device_id,
"url": placement.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),
}
def get_topology_rack_width_data(request):
"""Return permitted partial-width devices shown by Topology Views."""
if not topology_rack_width_enabled(request) or not request.GET:
return None
from dcim.models import Device, Rack
try:
racks = Rack.objects.restrict(request.user, "view")
selected_racks = request.GET.getlist("rack_id")
if selected_racks:
racks = racks.filter(pk__in=selected_racks)
else:
selected_sites = request.GET.getlist("site_id")
selected_locations = request.GET.getlist("location_id")
if selected_sites:
racks = racks.filter(site_id__in=selected_sites)
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,
)
placements = (
DeviceRackPlacement.objects.filter(device__in=devices).select_related("device").order_by("device_id")
)
return [serialize_topology_placement(placement) for placement in placements]
except (
AttributeError,
ObjectDoesNotExist,
OperationalError,
ProgrammingError,
TypeError,
ValueError,
):
# Keep NetBox usable while plugin migrations are being installed.
logger.warning("Could not load partial rack widths for NetBox Topology Views", exc_info=True)
return None
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "netbox-utilities"
version = "0.8.8"
version = "0.9.0"
description = "Navigation, tenant utilities, partial-width rack devices, bulk uploads, and rack reordering for NetBox 4.6"
readme = "README.md"
requires-python = ">=3.12"