diff --git a/README.md b/README.md
index c855c29..916acc3 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/netbox_utilities/__init__.py b/netbox_utilities/__init__.py
index c36809a..2a6e716 100644
--- a/netbox_utilities/__init__.py
+++ b/netbox_utilities/__init__.py
@@ -1,6 +1,6 @@
from netbox.plugins import PluginConfig, get_plugin_config
-__version__ = "0.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,
}
diff --git a/netbox_utilities/rack_width.py b/netbox_utilities/rack_width.py
index deebfdb..e0c2556 100644
--- a/netbox_utilities/rack_width.py
+++ b/netbox_utilities/rack_width.py
@@ -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
diff --git a/netbox_utilities/reorder_rack.py b/netbox_utilities/reorder_rack.py
index dad3581..037f2a2 100644
--- a/netbox_utilities/reorder_rack.py
+++ b/netbox_utilities/reorder_rack.py
@@ -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(
diff --git a/netbox_utilities/static/netbox_utilities/netbox_utilities.css b/netbox_utilities/static/netbox_utilities/netbox_utilities.css
index 3f44c04..831f1e1 100644
--- a/netbox_utilities/static/netbox_utilities/netbox_utilities.css
+++ b/netbox_utilities/static/netbox_utilities/netbox_utilities.css
@@ -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;
}
diff --git a/netbox_utilities/static/netbox_utilities/reorder-rack-width.js b/netbox_utilities/static/netbox_utilities/reorder-rack-width.js
new file mode 100644
index 0000000..3d98b0c
--- /dev/null
+++ b/netbox_utilities/static/netbox_utilities/reorder-rack-width.js
@@ -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});
+})();
diff --git a/netbox_utilities/static/netbox_utilities/topology-rack-width.js b/netbox_utilities/static/netbox_utilities/topology-rack-width.js
new file mode 100644
index 0000000..52f0171
--- /dev/null
+++ b/netbox_utilities/static/netbox_utilities/topology-rack-width.js
@@ -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 => ({
+ '&': '&',
+ '<': '<',
+ '>': '>',
+ '"': '"',
+ "'": ''',
+ })[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) =>
+ ``;
+ const drawioDocument = cells =>
+ `${cells}`;
+
+ 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 += ``;
+ };
+
+ 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}
U${device.position} · ${device.type}`
+ : 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 += `${xmlEscape(rack.name)}`;
+ content += `${xmlEscape(rack.site)}${rack.location ? ` · ${xmlEscape(rack.location)}` : ''} · ${rackUnits}U`;
+ ['front', 'rear'].forEach((face, faceIndex) => {
+ const faceX = baseX + labelWidth + faceIndex * (faceWidth + faceGap);
+ content += `${face === 'front' ? 'VORDERSEITE' : 'RÜCKSEITE'}`;
+ content += ``;
+ 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 += ``;
+ if (faceIndex === 0) {
+ content += `${unitLabel}`;
+ }
+ }
+ 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 += ``;
+ content += ``;
+ content += ``;
+ if (deviceHeight >= 13) {
+ content += `${xmlEscape(device.name)}`;
+ }
+ if (deviceHeight >= 31) {
+ content += `U${device.position} · ${xmlEscape(device.type)}`;
+ }
+ content += '';
+ });
+ });
+ });
+
+ const svg = svgDocument(width, height, `${definitions}${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);
+})();
diff --git a/netbox_utilities/template_content.py b/netbox_utilities/template_content.py
index d4ef5b1..3852fe0 100644
--- a/netbox_utilities/template_content.py
+++ b/netbox_utilities/template_content.py
@@ -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__,
},
)
diff --git a/netbox_utilities/templates/netbox_utilities/head.html b/netbox_utilities/templates/netbox_utilities/head.html
index 1463e68..eeb5728 100644
--- a/netbox_utilities/templates/netbox_utilities/head.html
+++ b/netbox_utilities/templates/netbox_utilities/head.html
@@ -8,3 +8,11 @@
{{ navigation_preferences|json_script:"netbox-utilities-navigation-data" }}
{% endif %}
+{% if topology_rack_width_data %}
+ {{ topology_rack_width_data|json_script:"netbox-utilities-topology-rack-width-data" }}
+
+{% endif %}
+{% if reorder_rack_width_data %}
+ {{ reorder_rack_width_data|json_script:"netbox-utilities-reorder-rack-width-data" }}
+
+{% endif %}
diff --git a/netbox_utilities/tests/test_rack_width.py b/netbox_utilities/tests/test_rack_width.py
index fdf9db2..f1c1458 100644
--- a/netbox_utilities/tests/test_rack_width.py
+++ b/netbox_utilities/tests/test_rack_width.py
@@ -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
diff --git a/netbox_utilities/tests/test_reorder_rack.py b/netbox_utilities/tests/test_reorder_rack.py
index 2396324..e30371b 100644
--- a/netbox_utilities/tests/test_reorder_rack.py
+++ b/netbox_utilities/tests/test_reorder_rack.py
@@ -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):
diff --git a/netbox_utilities/tests/test_topology_views.py b/netbox_utilities/tests/test_topology_views.py
new file mode 100644
index 0000000..90c2442
--- /dev/null
+++ b/netbox_utilities/tests/test_topology_views.py
@@ -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)
diff --git a/netbox_utilities/topology_views.py b/netbox_utilities/topology_views.py
new file mode 100644
index 0000000..85f7980
--- /dev/null
+++ b/netbox_utilities/topology_views.py
@@ -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
diff --git a/pyproject.toml b/pyproject.toml
index 6fed641..26b0338 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -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"