From 70825c40405b1c73d70ff0f70a1a21aebe2c7531 Mon Sep 17 00:00:00 2001 From: Louis Date: Wed, 12 Aug 2026 17:12:19 +0200 Subject: [PATCH] feat: integrate rack widths across plugins --- README.md | 67 +++- netbox_utilities/__init__.py | 3 +- netbox_utilities/rack_width.py | 19 +- netbox_utilities/reorder_rack.py | 227 ++++++++++- .../netbox_utilities/netbox_utilities.css | 7 + .../netbox_utilities/reorder-rack-width.js | 136 +++++++ .../netbox_utilities/topology-rack-width.js | 371 ++++++++++++++++++ netbox_utilities/template_content.py | 6 + .../templates/netbox_utilities/head.html | 8 + netbox_utilities/tests/test_rack_width.py | 57 ++- netbox_utilities/tests/test_reorder_rack.py | 196 ++++++++- netbox_utilities/tests/test_topology_views.py | 131 +++++++ netbox_utilities/topology_views.py | 80 ++++ pyproject.toml | 2 +- 14 files changed, 1267 insertions(+), 43 deletions(-) create mode 100644 netbox_utilities/static/netbox_utilities/reorder-rack-width.js create mode 100644 netbox_utilities/static/netbox_utilities/topology-rack-width.js create mode 100644 netbox_utilities/tests/test_topology_views.py create mode 100644 netbox_utilities/topology_views.py 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) => + `${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"