feat: integrate rack widths across plugins
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -5,19 +5,25 @@ from importlib import import_module
|
||||
|
||||
from dcim.models import Device, Rack
|
||||
from django.apps import apps
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import transaction
|
||||
from django.core.exceptions import ObjectDoesNotExist, ValidationError
|
||||
from django.db import OperationalError, ProgrammingError, transaction
|
||||
from django.http import Http404
|
||||
from django.shortcuts import get_object_or_404
|
||||
from netbox.plugins import get_plugin_config
|
||||
from rest_framework import status
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from rest_framework.response import Response
|
||||
from utilities.permissions import get_permission_for_model
|
||||
|
||||
from .models import DeviceRackPlacement
|
||||
from .rack_width import FULL_WIDTH, normalize_width_position, stage_width_position
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SUPPORTED_REORDER_RACK_VERSIONS = {"1.1.4"}
|
||||
PATCH_MARKER = "_netbox_utilities_bulk_save"
|
||||
GRID_COLUMNS = 12
|
||||
REORDER_RACK_VIEW = "dcim:rack_reorder"
|
||||
|
||||
|
||||
class RackLayoutError(Exception):
|
||||
@@ -29,9 +35,31 @@ class DevicePlacement:
|
||||
device_id: int
|
||||
position: Decimal | None
|
||||
face: str
|
||||
grid_x: int | None
|
||||
|
||||
|
||||
def parse_device_placements(layout):
|
||||
def grid_dimensions(width, horizontal_position):
|
||||
try:
|
||||
width, horizontal_position = normalize_width_position(width, horizontal_position)
|
||||
except ValidationError as error:
|
||||
raise RackLayoutError(str(error)) from error
|
||||
grid_width = GRID_COLUMNS // width
|
||||
return grid_width, (horizontal_position - 1) * grid_width
|
||||
|
||||
|
||||
def horizontal_position_from_grid_x(width, grid_x):
|
||||
if isinstance(grid_x, bool) or not isinstance(grid_x, int):
|
||||
raise RackLayoutError("Die horizontale Rasterposition muss eine ganze Zahl sein.")
|
||||
grid_width, _ = grid_dimensions(width, 1)
|
||||
if grid_x < 0 or grid_x > GRID_COLUMNS - grid_width or grid_x % grid_width:
|
||||
raise RackLayoutError(f"Rasterposition {grid_x} ist für 1/{width} Rackbreite nicht zulässig.")
|
||||
return grid_x // grid_width + 1
|
||||
|
||||
|
||||
def parse_device_placements(layout, *, grid_columns=None):
|
||||
if grid_columns not in {None, GRID_COLUMNS}:
|
||||
raise RackLayoutError(f"Das Rackraster muss {GRID_COLUMNS} Spalten verwenden.")
|
||||
|
||||
placements = []
|
||||
seen_device_ids = set()
|
||||
|
||||
@@ -53,6 +81,7 @@ def parse_device_placements(layout):
|
||||
|
||||
if section == "other":
|
||||
position = None
|
||||
grid_x = None
|
||||
else:
|
||||
try:
|
||||
position = Decimal(str(item["y"]))
|
||||
@@ -61,7 +90,13 @@ def parse_device_placements(layout):
|
||||
if not position.is_finite():
|
||||
raise RackLayoutError(f"Gerät {device_id} besitzt keine gültige Rack-Position.")
|
||||
|
||||
placements.append(DevicePlacement(device_id=device_id, position=position, face=face))
|
||||
grid_x = None
|
||||
if grid_columns == GRID_COLUMNS:
|
||||
grid_x = item.get("x")
|
||||
if isinstance(grid_x, bool) or not isinstance(grid_x, int) or not 0 <= grid_x < GRID_COLUMNS:
|
||||
raise RackLayoutError(f"Gerät {device_id} besitzt keine gültige horizontale Rasterposition.")
|
||||
|
||||
placements.append(DevicePlacement(device_id=device_id, position=position, face=face, grid_x=grid_x))
|
||||
|
||||
return placements
|
||||
|
||||
@@ -77,35 +112,81 @@ def apply_rack_layout(*, rack, placements, user):
|
||||
missing = ", ".join(str(device_id) for device_id in sorted(missing_ids))
|
||||
raise RackLayoutError(f"Diese Geräte gehören nicht zum gewählten Rack: {missing}.")
|
||||
|
||||
stored_placements = {
|
||||
placement.device_id: placement
|
||||
for placement in DeviceRackPlacement.objects.select_for_update().filter(device_id__in=placement_by_id)
|
||||
}
|
||||
target_placements = []
|
||||
for placement in placements:
|
||||
stored = stored_placements.get(placement.device_id)
|
||||
current_width = stored.width if stored else FULL_WIDTH
|
||||
current_horizontal_position = stored.horizontal_position if stored else 1
|
||||
if placement.position is None:
|
||||
target_width, target_horizontal_position = FULL_WIDTH, 1
|
||||
elif placement.grid_x is None:
|
||||
target_width = current_width
|
||||
target_horizontal_position = current_horizontal_position
|
||||
else:
|
||||
target_width = current_width
|
||||
target_horizontal_position = horizontal_position_from_grid_x(target_width, placement.grid_x)
|
||||
target_placements.append(
|
||||
(
|
||||
devices[placement.device_id],
|
||||
placement,
|
||||
target_width,
|
||||
target_horizontal_position,
|
||||
current_width,
|
||||
current_horizontal_position,
|
||||
)
|
||||
)
|
||||
|
||||
changed = [
|
||||
(devices[placement.device_id], placement)
|
||||
for placement in placements
|
||||
(device, placement, target_width, target_horizontal_position)
|
||||
for (
|
||||
device,
|
||||
placement,
|
||||
target_width,
|
||||
target_horizontal_position,
|
||||
current_width,
|
||||
current_horizontal_position,
|
||||
) in target_placements
|
||||
if (
|
||||
devices[placement.device_id].position != placement.position
|
||||
or devices[placement.device_id].face != placement.face
|
||||
device.position != placement.position
|
||||
or device.face != placement.face
|
||||
or current_width != target_width
|
||||
or current_horizontal_position != target_horizontal_position
|
||||
)
|
||||
]
|
||||
if not changed:
|
||||
return []
|
||||
|
||||
permission = get_permission_for_model(Device, "change")
|
||||
for device, _placement in changed:
|
||||
for device, _placement, _width, _horizontal_position in changed:
|
||||
if not user.has_perm(permission, obj=device):
|
||||
raise PermissionDenied(f"Keine Berechtigung zum Verschieben von {device}.")
|
||||
|
||||
for device, _placement in changed:
|
||||
for device, _placement, _width, _horizontal_position in changed:
|
||||
device.snapshot()
|
||||
|
||||
changed_ids = [device.pk for device, _placement in changed]
|
||||
changed_ids = [device.pk for device, _placement, _width, _horizontal_position in changed]
|
||||
Device.objects.filter(pk__in=changed_ids).update(position=None, face="")
|
||||
|
||||
for device, placement in changed:
|
||||
for device, placement, width, horizontal_position in changed:
|
||||
device.position = placement.position
|
||||
device.face = placement.face
|
||||
stage_width_position(device, width, horizontal_position)
|
||||
device.full_clean()
|
||||
device.save()
|
||||
|
||||
return [device for device, _placement in changed]
|
||||
if width == FULL_WIDTH or placement.position is None:
|
||||
DeviceRackPlacement.objects.filter(device=device).delete()
|
||||
else:
|
||||
DeviceRackPlacement.objects.update_or_create(
|
||||
device=device,
|
||||
defaults={"width": width, "horizontal_position": horizontal_position},
|
||||
)
|
||||
|
||||
return [device for device, _placement, _width, _horizontal_position in changed]
|
||||
|
||||
|
||||
def _validation_error_message(error):
|
||||
@@ -117,6 +198,119 @@ def _validation_error_message(error):
|
||||
return "; ".join(str(message) for message in error.messages)
|
||||
|
||||
|
||||
def is_reorder_rack_request(request):
|
||||
resolver_match = getattr(request, "resolver_match", None)
|
||||
return getattr(resolver_match, "view_name", None) == REORDER_RACK_VIEW
|
||||
|
||||
|
||||
def reorder_rack_width_enabled(request):
|
||||
if not (
|
||||
is_reorder_rack_request(request)
|
||||
and get_plugin_config("netbox_utilities", "reorder_rack_bulk_save_enabled")
|
||||
and apps.is_installed("netbox_reorder_rack")
|
||||
):
|
||||
return False
|
||||
plugin_config = apps.get_app_config("netbox_reorder_rack")
|
||||
return getattr(plugin_config, "version", None) in SUPPORTED_REORDER_RACK_VERSIONS
|
||||
|
||||
|
||||
def reorder_grid_y(rack, position, height):
|
||||
rack_height = int(Decimal(str(rack.u_height)) * 2)
|
||||
grid_height = int(Decimal(str(height)) * 2)
|
||||
unit_id = int(Decimal(str(position)) * 2)
|
||||
if rack.desc_units:
|
||||
return unit_id - 2
|
||||
if grid_height > 1:
|
||||
return rack_height - unit_id - grid_height + 2
|
||||
return rack_height - unit_id
|
||||
|
||||
|
||||
def _image_url(image):
|
||||
if not image:
|
||||
return ""
|
||||
try:
|
||||
return image.url
|
||||
except (AttributeError, ValueError):
|
||||
return ""
|
||||
|
||||
|
||||
def get_reorder_rack_width_data(request):
|
||||
"""Describe partial-width widgets for netbox-reorder-rack's GridStack UI."""
|
||||
if not reorder_rack_width_enabled(request):
|
||||
return None
|
||||
|
||||
rack_id = getattr(request.resolver_match, "kwargs", {}).get("pk")
|
||||
if not rack_id:
|
||||
return None
|
||||
|
||||
from dcim.svg.racks import get_device_name
|
||||
from netbox.config import get_config
|
||||
from utilities.html import foreground_color
|
||||
|
||||
try:
|
||||
rack = Rack.objects.restrict(request.user, "view").filter(pk=rack_id).first()
|
||||
if rack is None:
|
||||
return None
|
||||
devices = (
|
||||
Device.objects.restrict(request.user, "view")
|
||||
.filter(
|
||||
rack=rack,
|
||||
position__isnull=False,
|
||||
netbox_utilities_rack_placement__isnull=False,
|
||||
)
|
||||
.select_related("device_type", "role", "netbox_utilities_rack_placement")
|
||||
.order_by("pk")
|
||||
)
|
||||
permission = get_permission_for_model(Device, "change")
|
||||
descriptors = []
|
||||
for device in devices:
|
||||
placement = device.netbox_utilities_rack_placement
|
||||
grid_width, grid_x = grid_dimensions(placement.width, placement.horizontal_position)
|
||||
role_color = device.role.color or "1685fc"
|
||||
grid_height = int(Decimal(str(device.device_type.u_height)) * 2)
|
||||
descriptors.append(
|
||||
{
|
||||
"id": device.pk,
|
||||
"label": get_device_name(device),
|
||||
"face": device.face,
|
||||
"full_depth": device.device_type.is_full_depth,
|
||||
"grid_x": grid_x,
|
||||
"grid_y": reorder_grid_y(rack, device.position, device.device_type.u_height),
|
||||
"grid_width": grid_width,
|
||||
"grid_height": grid_height,
|
||||
"width": placement.width,
|
||||
"horizontal_position": placement.horizontal_position,
|
||||
"color": role_color,
|
||||
"text_color": foreground_color(role_color),
|
||||
"front_image": _image_url(device.device_type.front_image),
|
||||
"rear_image": _image_url(device.device_type.rear_image),
|
||||
"locked": not request.user.has_perm(permission, obj=device),
|
||||
}
|
||||
)
|
||||
if not descriptors:
|
||||
return None
|
||||
|
||||
selected_view = request.GET.get("view", "images-and-labels")
|
||||
return {
|
||||
"columns": GRID_COLUMNS,
|
||||
"unit_width": get_config().RACK_ELEVATION_DEFAULT_UNIT_WIDTH,
|
||||
"images": selected_view != "labels-only",
|
||||
"labels": selected_view != "images-only",
|
||||
"devices": descriptors,
|
||||
}
|
||||
except (
|
||||
AttributeError,
|
||||
ObjectDoesNotExist,
|
||||
OperationalError,
|
||||
ProgrammingError,
|
||||
RackLayoutError,
|
||||
TypeError,
|
||||
ValueError,
|
||||
):
|
||||
logger.warning("Could not load partial rack widths for netbox-reorder-rack", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def bulk_reorder_update(self, request, pk=None):
|
||||
serializer = self.serializer_class(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
@@ -128,7 +322,12 @@ def bulk_reorder_update(self, request, pk=None):
|
||||
if layout["rack_id"] != rack.pk:
|
||||
raise RackLayoutError("Die Rack-ID der Anfrage stimmt nicht mit dem Ziel-Rack überein.")
|
||||
|
||||
placements = parse_device_placements(layout)
|
||||
grid_columns_header = request.headers.get("X-NetBox-Utilities-Rack-Grid-Columns")
|
||||
try:
|
||||
grid_columns = int(grid_columns_header) if grid_columns_header is not None else None
|
||||
except (TypeError, ValueError):
|
||||
raise RackLayoutError("Das angegebene Rackraster ist ungültig.") from None
|
||||
placements = parse_device_placements(layout, grid_columns=grid_columns)
|
||||
changed_devices = apply_rack_layout(rack=rack, placements=placements, user=request.user)
|
||||
if not changed_devices:
|
||||
return Response(
|
||||
|
||||
@@ -29,6 +29,13 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rack-device.netbox-utilities-partial-width {
|
||||
box-sizing: border-box;
|
||||
right: auto !important;
|
||||
left: calc(var(--netbox-utilities-rack-device-left) + 3px) !important;
|
||||
width: calc(var(--netbox-utilities-rack-device-width) - 6px) !important;
|
||||
}
|
||||
|
||||
.netbox-utilities-sidebar-resizer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
(() => {
|
||||
'use strict';
|
||||
|
||||
const source = document.getElementById('netbox-utilities-reorder-rack-width-data');
|
||||
if (!source) return;
|
||||
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(source.textContent);
|
||||
} catch (error) {
|
||||
console.error('NetBox Utilities could not read the Reorder Rack width data.', error);
|
||||
return;
|
||||
}
|
||||
|
||||
const columns = Number.parseInt(data.columns, 10) || 12;
|
||||
const gridElements = {
|
||||
front: document.getElementById('grid-front'),
|
||||
rear: document.getElementById('grid-rear'),
|
||||
other: document.getElementById('grid-other'),
|
||||
};
|
||||
if (!gridElements.front || !gridElements.rear || !gridElements.other) return;
|
||||
|
||||
const setAttribute = (element, name, value) => element.setAttribute(name, String(value));
|
||||
const partialDeviceIds = new Set(data.devices.map(device => String(device.id)));
|
||||
|
||||
const originalFetch = window.fetch.bind(window);
|
||||
window.fetch = (resource, options = {}) => {
|
||||
const resourceUrl = typeof resource === 'string' || resource instanceof URL
|
||||
? resource
|
||||
: resource?.url;
|
||||
let url;
|
||||
try {
|
||||
url = new URL(resourceUrl, document.baseURI);
|
||||
} catch (_) {
|
||||
return originalFetch(resource, options);
|
||||
}
|
||||
const method = String(options.method || resource?.method || 'GET').toUpperCase();
|
||||
if (method !== 'PUT' || !/\/api\/plugins\/reorder\/save\/\d+\/?$/.test(url.pathname)) {
|
||||
return originalFetch(resource, options);
|
||||
}
|
||||
|
||||
const headers = new Headers(options.headers || resource?.headers);
|
||||
headers.set('X-NetBox-Utilities-Rack-Grid-Columns', String(columns));
|
||||
return originalFetch(resource, {...options, headers});
|
||||
};
|
||||
|
||||
Object.values(gridElements).forEach(grid => {
|
||||
setAttribute(grid, 'gs-column', columns);
|
||||
grid.querySelectorAll('.grid-stack-item').forEach(item => {
|
||||
if (partialDeviceIds.has(item.getAttribute('gs-id'))) {
|
||||
item.remove();
|
||||
return;
|
||||
}
|
||||
setAttribute(item, 'gs-w', columns);
|
||||
setAttribute(item, 'gs-x', 0);
|
||||
item.dataset.rackWidth = '1';
|
||||
item.dataset.horizontalPosition = '1';
|
||||
});
|
||||
});
|
||||
|
||||
const addWidget = (device, gridFace, itemFace, rearSide = false) => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'grid-stack-item netbox-utilities-reorder-rack-device';
|
||||
setAttribute(item, 'gs-w', device.grid_width);
|
||||
setAttribute(item, 'gs-h', device.grid_height);
|
||||
setAttribute(item, 'gs-x', device.grid_x);
|
||||
setAttribute(item, 'gs-y', device.grid_y);
|
||||
setAttribute(item, 'gs-id', device.id);
|
||||
setAttribute(item, 'gs-locked', device.locked ? 'true' : 'false');
|
||||
setAttribute(item, 'gs-no-move', device.locked ? 'true' : 'false');
|
||||
item.dataset.itemColor = device.color;
|
||||
item.dataset.itemTextColor = device.text_color;
|
||||
item.dataset.fullDepth = device.full_depth ? 'True' : 'False';
|
||||
item.dataset.itemFace = itemFace;
|
||||
item.dataset.rackWidth = String(device.width);
|
||||
item.dataset.horizontalPosition = String(device.horizontal_position);
|
||||
|
||||
const content = document.createElement('div');
|
||||
content.className = 'grid-stack-item-content';
|
||||
if (rearSide) content.classList.add('device_rear');
|
||||
const image = rearSide ? device.rear_image : device.front_image;
|
||||
if (image && data.images) {
|
||||
content.style.backgroundImage = `url("${String(image).replaceAll('"', '\\"')}")`;
|
||||
content.style.backgroundSize = `${data.unit_width}px`;
|
||||
content.style.color = `#${device.text_color}`;
|
||||
if (data.labels) content.textContent = device.label;
|
||||
} else {
|
||||
if (!rearSide) content.style.backgroundColor = `#${device.color}`;
|
||||
content.style.color = rearSide ? '#000000' : `#${device.text_color}`;
|
||||
content.textContent = device.label;
|
||||
}
|
||||
item.appendChild(content);
|
||||
gridElements[gridFace].appendChild(item);
|
||||
};
|
||||
|
||||
data.devices.forEach(device => {
|
||||
const face = device.face === 'rear' ? 'rear' : 'front';
|
||||
addWidget(device, face, face);
|
||||
if (device.full_depth) {
|
||||
addWidget(device, face === 'front' ? 'rear' : 'front', 'back', true);
|
||||
}
|
||||
});
|
||||
|
||||
const snapElement = element => {
|
||||
if (!element?.gridstackNode) return;
|
||||
const width = Number.parseInt(element.dataset.rackWidth || '1', 10);
|
||||
if (![1, 2, 3, 4].includes(width)) return;
|
||||
const gridWidth = columns / width;
|
||||
const node = element.gridstackNode;
|
||||
const gridX = Math.max(0, Math.min(columns - gridWidth, Math.round(node.x / gridWidth) * gridWidth));
|
||||
element.dataset.horizontalPosition = String(gridX / gridWidth + 1);
|
||||
if (node.x !== gridX || node.w !== gridWidth) {
|
||||
node.grid.update(element, {x: gridX, w: gridWidth});
|
||||
}
|
||||
};
|
||||
|
||||
const snapGrid = grid => grid.getGridItems().forEach(snapElement);
|
||||
const attachSnapping = () => {
|
||||
Object.values(gridElements).forEach(element => {
|
||||
const grid = element.gridstack;
|
||||
if (!grid) return;
|
||||
grid.on('dragstop', (_event, item) => snapElement(item));
|
||||
grid.on('dropped', (_event, _previous, current) => snapElement(current?.el));
|
||||
});
|
||||
};
|
||||
|
||||
document.addEventListener('click', event => {
|
||||
const target = event.target instanceof Element ? event.target : event.target?.parentElement;
|
||||
if (!target?.closest('#saveButton')) return;
|
||||
Object.values(gridElements).forEach(element => {
|
||||
if (element.gridstack) snapGrid(element.gridstack);
|
||||
});
|
||||
}, true);
|
||||
|
||||
document.addEventListener('DOMContentLoaded', attachSnapping, {once: true});
|
||||
})();
|
||||
@@ -0,0 +1,371 @@
|
||||
(() => {
|
||||
'use strict';
|
||||
|
||||
const placementSource = document.getElementById('netbox-utilities-topology-rack-width-data');
|
||||
if (!placementSource) return;
|
||||
|
||||
let placementData;
|
||||
try {
|
||||
placementData = JSON.parse(placementSource.textContent);
|
||||
} catch (error) {
|
||||
console.error('NetBox Utilities could not read the Topology Views rack widths.', error);
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizePath = value => {
|
||||
try {
|
||||
return new URL(value, document.baseURI).pathname.replace(/\/+$/, '/');
|
||||
} catch (_) {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const normalizePlacement = value => {
|
||||
const width = Number.parseInt(value?.width, 10);
|
||||
const horizontalPosition = Number.parseInt(value?.horizontal_position, 10);
|
||||
if (![2, 3, 4].includes(width) || horizontalPosition < 1 || horizontalPosition > width) return null;
|
||||
return {
|
||||
width,
|
||||
horizontalPosition,
|
||||
leftPercent: Number.isFinite(Number(value.left_percent))
|
||||
? Number(value.left_percent)
|
||||
: (horizontalPosition - 1) / width * 100,
|
||||
widthPercent: Number.isFinite(Number(value.width_percent))
|
||||
? Number(value.width_percent)
|
||||
: 100 / width,
|
||||
};
|
||||
};
|
||||
|
||||
const placementsByPath = new Map();
|
||||
const placementsByDeviceId = new Map();
|
||||
placementData.forEach(item => {
|
||||
const placement = normalizePlacement(item);
|
||||
if (!placement) return;
|
||||
const path = normalizePath(item.url);
|
||||
if (path) placementsByPath.set(path, placement);
|
||||
placementsByDeviceId.set(String(item.device_id), placement);
|
||||
});
|
||||
|
||||
const rackCards = Array.from(document.querySelectorAll('.rack-card[data-rack-id][data-rack-units]'));
|
||||
const liveViewCompatible = rackCards.length > 0 && rackCards.every(card => {
|
||||
if (!/^\d+$/.test(String(card.dataset.rackId)) || !Number.isFinite(Number(card.dataset.rackUnits))) {
|
||||
return false;
|
||||
}
|
||||
return Array.from(card.querySelectorAll('.rack-device')).every(device => (
|
||||
device.hasAttribute('href')
|
||||
&& ['front', 'rear'].includes(device.dataset.face)
|
||||
&& Number.isFinite(Number(device.dataset.position))
|
||||
&& Number.isFinite(Number(device.dataset.deviceHeight))
|
||||
&& typeof device.dataset.deviceColor === 'string'
|
||||
));
|
||||
});
|
||||
if (!liveViewCompatible) {
|
||||
console.warn('NetBox Utilities left Topology Views unchanged because its rack DOM is not compatible.');
|
||||
return;
|
||||
}
|
||||
|
||||
const placementForElement = element => {
|
||||
const path = normalizePath(element.getAttribute('href'));
|
||||
if (placementsByPath.has(path)) return placementsByPath.get(path);
|
||||
const deviceId = path.match(/\/devices\/(\d+)\/?$/)?.[1];
|
||||
return deviceId ? placementsByDeviceId.get(deviceId) : null;
|
||||
};
|
||||
|
||||
document.querySelectorAll('.rack-device').forEach(device => {
|
||||
const placement = placementForElement(device);
|
||||
if (!placement) return;
|
||||
device.classList.add('netbox-utilities-partial-width');
|
||||
device.dataset.rackWidth = String(placement.width);
|
||||
device.dataset.horizontalPosition = String(placement.horizontalPosition);
|
||||
device.style.setProperty('--netbox-utilities-rack-device-left', `${placement.leftPercent}%`);
|
||||
device.style.setProperty('--netbox-utilities-rack-device-width', `${placement.widthPercent}%`);
|
||||
});
|
||||
|
||||
const rackDataSource = document.getElementById('rack-export-data');
|
||||
if (!rackDataSource) return;
|
||||
|
||||
let rackData;
|
||||
try {
|
||||
rackData = JSON.parse(rackDataSource.textContent);
|
||||
} catch (error) {
|
||||
console.error('NetBox Utilities could not read the Topology Views rack export data.', error);
|
||||
return;
|
||||
}
|
||||
|
||||
const rackExportCompatible = Array.isArray(rackData) && rackData.length > 0 && rackData.every(rack => {
|
||||
if (!rack || !/^\d+$/.test(String(rack.id)) || !Array.isArray(rack.devices)) return false;
|
||||
if (!Number.isFinite(Number(rack.u_height)) || !Number.isFinite(Number(rack.starting_unit))) return false;
|
||||
const card = document.querySelector(`.rack-card[data-rack-id="${rack.id}"]`);
|
||||
if (!card || !Number.isFinite(Number(card.dataset.rackUnits))) return false;
|
||||
return true;
|
||||
});
|
||||
if (!rackExportCompatible) {
|
||||
console.warn('NetBox Utilities left Topology Views exports unchanged because their schema is not compatible.');
|
||||
return;
|
||||
}
|
||||
|
||||
const xmlEscape = value => String(value ?? '').replace(/[&<>"']/g, character => ({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
})[character]);
|
||||
const safeName = value => String(value || 'export')
|
||||
.replace(/[^a-z0-9._-]+/gi, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
const numberOr = (value, fallback) => {
|
||||
const number = Number.parseFloat(value);
|
||||
return Number.isFinite(number) ? number : fallback;
|
||||
};
|
||||
|
||||
const activeNetBoxTheme = () => {
|
||||
for (const element of [document.body, document.documentElement]) {
|
||||
const match = getComputedStyle(element).backgroundColor.match(/[\d.]+/g);
|
||||
if (match && match.length >= 3 && (match.length < 4 || Number(match[3]) > 0)) {
|
||||
const [red, green, blue] = match.map(Number);
|
||||
return (.2126 * red + .7152 * green + .0722 * blue) < 140 ? 'dark' : 'light';
|
||||
}
|
||||
}
|
||||
return document.body.dataset.bsTheme || document.documentElement.dataset.bsTheme || 'light';
|
||||
};
|
||||
|
||||
const exportPalette = () => activeNetBoxTheme() === 'dark' ? {
|
||||
frame: '#52606d',
|
||||
grid: '#667382',
|
||||
heading: '#8fc8ff',
|
||||
muted: '#a9b7c6',
|
||||
deviceFill: '#162e45',
|
||||
deviceText: '#ffffff',
|
||||
deviceSubtext: '#cad5df',
|
||||
} : {
|
||||
frame: '#718096',
|
||||
grid: '#8b99a8',
|
||||
heading: '#0b69b7',
|
||||
muted: '#52606d',
|
||||
deviceFill: '#eef6ff',
|
||||
deviceText: '#132238',
|
||||
deviceSubtext: '#52606d',
|
||||
};
|
||||
|
||||
const exportPlacement = device => {
|
||||
const width = Number.parseInt(device.dataset.rackWidth || '1', 10);
|
||||
const horizontalPosition = Number.parseInt(device.dataset.horizontalPosition || '1', 10);
|
||||
if (![1, 2, 3, 4].includes(width) || horizontalPosition < 1 || horizontalPosition > width) {
|
||||
return {width: 1, horizontalPosition: 1};
|
||||
}
|
||||
return {width, horizontalPosition};
|
||||
};
|
||||
|
||||
const renderedRackData = racks => racks.map(rack => {
|
||||
const card = document.querySelector(`.rack-card[data-rack-id="${rack.id}"]`);
|
||||
if (!card) return rack;
|
||||
const rackUnits = numberOr(card.dataset.rackUnits, numberOr(rack.u_height, 1));
|
||||
const devices = Array.from(card.querySelectorAll('.rack-device')).map(device => {
|
||||
const placement = exportPlacement(device);
|
||||
return {
|
||||
name: device.dataset.deviceName || device.querySelector('span')?.textContent || 'Gerät',
|
||||
type: device.dataset.deviceType || '',
|
||||
position: numberOr(device.dataset.position, numberOr(rack.starting_unit, 1)),
|
||||
height: Math.max(numberOr(device.dataset.deviceHeight, 100 / rackUnits), 1),
|
||||
face: device.dataset.face === 'rear' ? 'rear' : 'front',
|
||||
color: device.dataset.deviceColor || '#1685fc',
|
||||
width: placement.width,
|
||||
horizontalPosition: placement.horizontalPosition,
|
||||
};
|
||||
});
|
||||
return {...rack, devices};
|
||||
});
|
||||
|
||||
const horizontalGeometry = (faceX, faceWidth, device) => {
|
||||
const width = [1, 2, 3, 4].includes(device.width) ? device.width : 1;
|
||||
const horizontalPosition = device.horizontalPosition >= 1 && device.horizontalPosition <= width
|
||||
? device.horizontalPosition
|
||||
: 1;
|
||||
const slotWidth = faceWidth / width;
|
||||
return {
|
||||
x: faceX + (horizontalPosition - 1) * slotWidth + 3,
|
||||
width: Math.max(slotWidth - 6, 1),
|
||||
};
|
||||
};
|
||||
|
||||
const svgDocument = (width, height, content) =>
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">${content}</svg>`;
|
||||
const drawioDocument = cells =>
|
||||
`<mxfile host="app.diagrams.net" type="device"><diagram name="Rack export"><mxGraphModel page="0" background="none" grid="1" guides="1" connect="1"><root><mxCell id="0"/><mxCell id="1" parent="0"/>${cells}</root></mxGraphModel></diagram></mxfile>`;
|
||||
|
||||
const buildRackDrawio = racks => {
|
||||
const palette = exportPalette();
|
||||
const unit = 22;
|
||||
const faceWidth = 250;
|
||||
const labelWidth = 28;
|
||||
const faceGap = 28;
|
||||
const rackGap = 44;
|
||||
const header = 55;
|
||||
const rackWidth = labelWidth + faceWidth * 2 + faceGap;
|
||||
let cells = '';
|
||||
let cellId = 2;
|
||||
const vertex = (value, style, x, y, width, height) => {
|
||||
cells += `<mxCell id="${cellId++}" value="${xmlEscape(value)}" style="${style}" vertex="1" parent="1"><mxGeometry x="${x}" y="${y}" width="${width}" height="${height}" as="geometry"/></mxCell>`;
|
||||
};
|
||||
|
||||
racks.forEach((rack, rackIndex) => {
|
||||
const rackUnits = numberOr(rack.u_height, 1);
|
||||
const baseX = 10 + rackIndex * (rackWidth + rackGap);
|
||||
const rackHeight = rackUnits * unit;
|
||||
const startingUnit = numberOr(rack.starting_unit, 1);
|
||||
vertex(rack.name, `text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;fontSize=16;fontStyle=1;fontColor=${palette.heading};`, baseX, 0, rackWidth, 25);
|
||||
vertex(`${rack.site}${rack.location ? ` · ${rack.location}` : ''} · ${rackUnits}U`, `text;html=1;strokeColor=none;fillColor=none;align=left;fontSize=10;fontColor=${palette.muted};`, baseX, 24, rackWidth, 20);
|
||||
['front', 'rear'].forEach((face, faceIndex) => {
|
||||
const faceX = baseX + labelWidth + faceIndex * (faceWidth + faceGap);
|
||||
vertex(face === 'front' ? 'VORDERSEITE' : 'RÜCKSEITE', `text;html=1;strokeColor=none;fillColor=none;align=center;fontSize=10;fontColor=${palette.muted};`, faceX, 38, faceWidth, 17);
|
||||
vertex('', `rounded=0;html=1;fillColor=none;strokeColor=${palette.frame};strokeWidth=4;`, faceX, header, faceWidth, rackHeight);
|
||||
for (let row = 0; row < rackUnits; row += 1) {
|
||||
const y = header + row * unit;
|
||||
const unitLabel = rack.desc_units ? startingUnit + row : startingUnit + rackUnits - 1 - row;
|
||||
if (faceIndex === 0) {
|
||||
vertex(String(unitLabel), `text;html=1;strokeColor=none;fillColor=none;align=right;fontSize=8;fontColor=${palette.muted};`, faceX - labelWidth, y, labelWidth - 4, unit);
|
||||
}
|
||||
}
|
||||
rack.devices.filter(device => device.face === face).forEach(device => {
|
||||
const positionOffset = device.position - startingUnit;
|
||||
const y = rack.desc_units
|
||||
? header + positionOffset * unit
|
||||
: header + rackHeight - (positionOffset + device.height) * unit;
|
||||
const height = Math.max(device.height * unit, 4);
|
||||
const geometry = horizontalGeometry(faceX, faceWidth, device);
|
||||
const value = height >= 31
|
||||
? `${device.name}<br><font style="font-size:9px">U${device.position} · ${device.type}</font>`
|
||||
: device.name;
|
||||
vertex(value, `rounded=1;html=1;whiteSpace=wrap;overflow=hidden;align=left;verticalAlign=top;spacingLeft=5;spacingTop=2;fillColor=${palette.deviceFill};strokeColor=${device.color};strokeWidth=2;fontColor=${palette.deviceText};fontSize=11;`, geometry.x, y + 1, geometry.width, height - 2);
|
||||
});
|
||||
});
|
||||
});
|
||||
return drawioDocument(cells);
|
||||
};
|
||||
|
||||
const buildRackSvg = racks => {
|
||||
const palette = exportPalette();
|
||||
const unit = 22;
|
||||
const faceWidth = 250;
|
||||
const labelWidth = 28;
|
||||
const faceGap = 28;
|
||||
const rackGap = 44;
|
||||
const header = 55;
|
||||
const rackWidth = labelWidth + faceWidth * 2 + faceGap;
|
||||
const maxUnits = Math.max(...racks.map(rack => numberOr(rack.u_height, 1)), 1);
|
||||
const width = racks.length * rackWidth + Math.max(0, racks.length - 1) * rackGap + 20;
|
||||
const height = header + maxUnits * unit + 25;
|
||||
let definitions = '';
|
||||
let content = '';
|
||||
|
||||
racks.forEach((rack, rackIndex) => {
|
||||
const rackUnits = numberOr(rack.u_height, 1);
|
||||
const baseX = 10 + rackIndex * (rackWidth + rackGap);
|
||||
const rackHeight = rackUnits * unit;
|
||||
const startingUnit = numberOr(rack.starting_unit, 1);
|
||||
content += `<text x="${baseX}" y="20" fill="${palette.heading}" font-family="sans-serif" font-size="16" font-weight="700">${xmlEscape(rack.name)}</text>`;
|
||||
content += `<text x="${baseX}" y="39" fill="${palette.muted}" font-family="sans-serif" font-size="11">${xmlEscape(rack.site)}${rack.location ? ` · ${xmlEscape(rack.location)}` : ''} · ${rackUnits}U</text>`;
|
||||
['front', 'rear'].forEach((face, faceIndex) => {
|
||||
const faceX = baseX + labelWidth + faceIndex * (faceWidth + faceGap);
|
||||
content += `<text x="${faceX + faceWidth / 2}" y="51" text-anchor="middle" fill="${palette.muted}" font-family="sans-serif" font-size="10">${face === 'front' ? 'VORDERSEITE' : 'RÜCKSEITE'}</text>`;
|
||||
content += `<rect x="${faceX}" y="${header}" width="${faceWidth}" height="${rackHeight}" fill="none" stroke="${palette.frame}" stroke-width="4"/>`;
|
||||
for (let row = 0; row < rackUnits; row += 1) {
|
||||
const y = header + row * unit;
|
||||
const unitLabel = rack.desc_units ? startingUnit + row : startingUnit + rackUnits - 1 - row;
|
||||
content += `<line x1="${faceX}" y1="${y}" x2="${faceX + faceWidth}" y2="${y}" stroke="${palette.grid}" stroke-opacity=".35"/>`;
|
||||
if (faceIndex === 0) {
|
||||
content += `<text x="${faceX - 6}" y="${y + 15}" text-anchor="end" fill="${palette.muted}" font-family="sans-serif" font-size="9">${unitLabel}</text>`;
|
||||
}
|
||||
}
|
||||
rack.devices.filter(device => device.face === face).forEach((device, deviceIndex) => {
|
||||
const positionOffset = device.position - startingUnit;
|
||||
const y = rack.desc_units
|
||||
? header + positionOffset * unit
|
||||
: header + rackHeight - (positionOffset + device.height) * unit;
|
||||
const deviceHeight = Math.max(device.height * unit, 4);
|
||||
const geometry = horizontalGeometry(faceX, faceWidth, device);
|
||||
const clipId = `netbox-utilities-rack-${rackIndex}-${faceIndex}-${deviceIndex}`;
|
||||
definitions += `<clipPath id="${clipId}"><rect x="${geometry.x}" y="${y + 1}" width="${geometry.width}" height="${deviceHeight - 2}"/></clipPath>`;
|
||||
content += `<g clip-path="url(#${clipId})">`;
|
||||
content += `<rect x="${geometry.x}" y="${y + 1}" width="${geometry.width}" height="${deviceHeight - 2}" rx="3" fill="${palette.deviceFill}" stroke="${xmlEscape(device.color)}" stroke-width="2"/>`;
|
||||
if (deviceHeight >= 13) {
|
||||
content += `<text x="${geometry.x + 6}" y="${y + Math.min(15, deviceHeight - 4)}" fill="${palette.deviceText}" font-family="sans-serif" font-size="11" font-weight="600">${xmlEscape(device.name)}</text>`;
|
||||
}
|
||||
if (deviceHeight >= 31) {
|
||||
content += `<text x="${geometry.x + 6}" y="${y + 28}" fill="${palette.deviceSubtext}" font-family="sans-serif" font-size="9">U${device.position} · ${xmlEscape(device.type)}</text>`;
|
||||
}
|
||||
content += '</g>';
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const svg = svgDocument(width, height, `<defs>${definitions}</defs>${content}`);
|
||||
return {svg, drawio: buildRackDrawio(racks), width, height};
|
||||
};
|
||||
|
||||
const downloadBlob = (blob, filename) => {
|
||||
const link = document.createElement('a');
|
||||
link.href = URL.createObjectURL(blob);
|
||||
link.download = filename;
|
||||
link.click();
|
||||
setTimeout(() => URL.revokeObjectURL(link.href), 1000);
|
||||
};
|
||||
|
||||
const exportGraphic = (graphic, format, filename) => {
|
||||
if (format === 'svg') {
|
||||
downloadBlob(new Blob([graphic.svg], {type: 'image/svg+xml'}), `${filename}.svg`);
|
||||
return;
|
||||
}
|
||||
if (format === 'drawio') {
|
||||
downloadBlob(new Blob([graphic.drawio], {type: 'application/xml'}), `${filename}.drawio`);
|
||||
return;
|
||||
}
|
||||
|
||||
const image = new Image();
|
||||
const url = URL.createObjectURL(new Blob([graphic.svg], {type: 'image/svg+xml'}));
|
||||
image.onload = () => {
|
||||
const scale = 2;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = graphic.width * scale;
|
||||
canvas.height = graphic.height * scale;
|
||||
const context = canvas.getContext('2d');
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
context.scale(scale, scale);
|
||||
context.drawImage(image, 0, 0);
|
||||
canvas.toBlob(blob => downloadBlob(blob, `${filename}.png`), 'image/png');
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
image.onerror = () => URL.revokeObjectURL(url);
|
||||
image.src = url;
|
||||
};
|
||||
|
||||
const selectedRacks = button => button.dataset.scope === 'all'
|
||||
? rackData
|
||||
: rackData.filter(rack => String(rack.id) === button.dataset.scope);
|
||||
const containsPartialWidthDevice = racks => racks.some(rack =>
|
||||
document.querySelector(`.rack-card[data-rack-id="${rack.id}"] .netbox-utilities-partial-width`));
|
||||
|
||||
document.addEventListener('click', event => {
|
||||
const target = event.target instanceof Element ? event.target : event.target?.parentElement;
|
||||
const button = target?.closest('.rack-export');
|
||||
if (!button) return;
|
||||
|
||||
const selection = selectedRacks(button);
|
||||
if (!selection.length || !containsPartialWidthDevice(selection)) return;
|
||||
|
||||
try {
|
||||
const racks = renderedRackData(selection);
|
||||
const graphic = buildRackSvg(racks);
|
||||
const filename = button.dataset.scope === 'all'
|
||||
? 'racks-gesamt'
|
||||
: `rack-${safeName(racks[0]?.name)}`;
|
||||
exportGraphic(graphic, button.dataset.format, filename);
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
} catch (error) {
|
||||
// Let Topology Views' original click handler provide its normal export fallback.
|
||||
console.error('NetBox Utilities could not create the width-aware rack export.', error);
|
||||
}
|
||||
}, true);
|
||||
})();
|
||||
@@ -14,12 +14,16 @@ from .navigation_helpers import (
|
||||
normalize_preferences,
|
||||
normalize_sidebar_width,
|
||||
)
|
||||
from .reorder_rack import get_reorder_rack_width_data
|
||||
from .runtime import navigation_customization_enabled, tenant_filter_enabled
|
||||
from .topology_views import get_topology_rack_width_data
|
||||
|
||||
|
||||
class UtilitiesGlobalContent(PluginTemplateExtension):
|
||||
def head(self):
|
||||
request = self.context["request"]
|
||||
reorder_rack_width_data = get_reorder_rack_width_data(request)
|
||||
topology_rack_width_data = get_topology_rack_width_data(request)
|
||||
preference_data = {
|
||||
"order": [],
|
||||
"hidden": [],
|
||||
@@ -56,6 +60,8 @@ class UtilitiesGlobalContent(PluginTemplateExtension):
|
||||
},
|
||||
"navigation_enabled": request.user.is_authenticated and navigation_customization_enabled(),
|
||||
"navigation_preferences": preference_data,
|
||||
"reorder_rack_width_data": reorder_rack_width_data,
|
||||
"topology_rack_width_data": topology_rack_width_data,
|
||||
"asset_version": __version__,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -8,3 +8,11 @@
|
||||
{{ navigation_preferences|json_script:"netbox-utilities-navigation-data" }}
|
||||
<script src="{% static 'netbox_utilities/navigation.js' %}?v={{ asset_version }}" defer></script>
|
||||
{% endif %}
|
||||
{% if topology_rack_width_data %}
|
||||
{{ topology_rack_width_data|json_script:"netbox-utilities-topology-rack-width-data" }}
|
||||
<script src="{% static 'netbox_utilities/topology-rack-width.js' %}?v={{ asset_version }}" defer></script>
|
||||
{% endif %}
|
||||
{% if reorder_rack_width_data %}
|
||||
{{ reorder_rack_width_data|json_script:"netbox-utilities-reorder-rack-width-data" }}
|
||||
<script src="{% static 'netbox_utilities/reorder-rack-width.js' %}?v={{ asset_version }}" defer></script>
|
||||
{% endif %}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from django.test import SimpleTestCase
|
||||
from django.test import RequestFactory, SimpleTestCase
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
|
||||
from netbox_utilities.reorder_rack import (
|
||||
RackLayoutError,
|
||||
apply_rack_layout,
|
||||
bulk_reorder_update,
|
||||
get_reorder_rack_width_data,
|
||||
grid_dimensions,
|
||||
horizontal_position_from_grid_x,
|
||||
install_reorder_rack_bulk_save,
|
||||
parse_device_placements,
|
||||
reorder_grid_y,
|
||||
)
|
||||
|
||||
|
||||
@@ -32,14 +37,41 @@ class RackLayoutParsingTest(SimpleTestCase):
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
[(item.device_id, item.position, item.face) for item in placements],
|
||||
[(item.device_id, item.position, item.face, item.grid_x) for item in placements],
|
||||
[
|
||||
(10, Decimal(4), "front"),
|
||||
(11, Decimal("8.5"), "front"),
|
||||
(12, None, ""),
|
||||
(10, Decimal(4), "front", None),
|
||||
(11, Decimal("8.5"), "front", None),
|
||||
(12, None, "", None),
|
||||
],
|
||||
)
|
||||
|
||||
def test_reads_horizontal_positions_only_from_marked_twelve_column_payload(self):
|
||||
placements = parse_device_placements(
|
||||
rack_layout(front=[{"id": 10, "x": 6, "y": 4}]),
|
||||
grid_columns=12,
|
||||
)
|
||||
|
||||
self.assertEqual(placements[0].grid_x, 6)
|
||||
|
||||
def test_maps_rack_widths_to_twelve_column_grid(self):
|
||||
self.assertEqual(grid_dimensions(1, 1), (12, 0))
|
||||
self.assertEqual(grid_dimensions(2, 2), (6, 6))
|
||||
self.assertEqual(grid_dimensions(3, 2), (4, 4))
|
||||
self.assertEqual(grid_dimensions(4, 4), (3, 9))
|
||||
|
||||
def test_maps_grid_x_back_to_horizontal_position(self):
|
||||
self.assertEqual(horizontal_position_from_grid_x(2, 6), 2)
|
||||
self.assertEqual(horizontal_position_from_grid_x(3, 8), 3)
|
||||
self.assertEqual(horizontal_position_from_grid_x(4, 9), 4)
|
||||
|
||||
def test_rejects_misaligned_grid_position(self):
|
||||
with self.assertRaisesMessage(RackLayoutError, "nicht zulässig"):
|
||||
horizontal_position_from_grid_x(2, 3)
|
||||
|
||||
def test_rejects_non_integer_grid_position(self):
|
||||
with self.assertRaisesMessage(RackLayoutError, "ganze Zahl"):
|
||||
horizontal_position_from_grid_x(2, 6.0)
|
||||
|
||||
def test_rejects_duplicate_devices(self):
|
||||
with self.assertRaisesMessage(RackLayoutError, "mehrfach"):
|
||||
parse_device_placements(
|
||||
@@ -55,9 +87,15 @@ class RackLayoutParsingTest(SimpleTestCase):
|
||||
|
||||
|
||||
class ApplyRackLayoutTest(SimpleTestCase):
|
||||
@patch("netbox_utilities.reorder_rack.DeviceRackPlacement")
|
||||
@patch("netbox_utilities.reorder_rack.get_permission_for_model", return_value="dcim.change_device")
|
||||
@patch("netbox_utilities.reorder_rack.Device")
|
||||
def test_clears_all_old_positions_before_saving_targets(self, device_model, _get_permission):
|
||||
def test_clears_all_old_positions_before_saving_targets(
|
||||
self,
|
||||
device_model,
|
||||
_get_permission,
|
||||
placement_model,
|
||||
):
|
||||
events = []
|
||||
first = MagicMock(pk=10, position=Decimal(1), face="front")
|
||||
second = MagicMock(pk=11, position=Decimal(2), face="front")
|
||||
@@ -70,6 +108,7 @@ class ApplyRackLayoutTest(SimpleTestCase):
|
||||
|
||||
device_model.objects.select_for_update.return_value.filter.return_value = [first, second]
|
||||
device_model.objects.filter.return_value.update.side_effect = lambda **_kwargs: events.append("clear")
|
||||
placement_model.objects.select_for_update.return_value.filter.return_value = []
|
||||
user = MagicMock()
|
||||
user.has_perm.return_value = True
|
||||
placements = parse_device_placements(rack_layout(front=[{"id": 10, "y": 2}, {"id": 11, "y": 1}]))
|
||||
@@ -83,11 +122,13 @@ class ApplyRackLayoutTest(SimpleTestCase):
|
||||
self.assertEqual((first.position, first.face), (Decimal(2), "front"))
|
||||
self.assertEqual((second.position, second.face), (Decimal(1), "front"))
|
||||
|
||||
@patch("netbox_utilities.reorder_rack.DeviceRackPlacement")
|
||||
@patch("netbox_utilities.reorder_rack.get_permission_for_model", return_value="dcim.change_device")
|
||||
@patch("netbox_utilities.reorder_rack.Device")
|
||||
def test_rejects_changed_device_without_permission(self, device_model, _get_permission):
|
||||
def test_rejects_changed_device_without_permission(self, device_model, _get_permission, placement_model):
|
||||
device = MagicMock(pk=10, position=Decimal(1), face="front")
|
||||
device_model.objects.select_for_update.return_value.filter.return_value = [device]
|
||||
placement_model.objects.select_for_update.return_value.filter.return_value = []
|
||||
user = MagicMock()
|
||||
user.has_perm.return_value = False
|
||||
placements = parse_device_placements(rack_layout(front=[{"id": 10, "y": 2}]))
|
||||
@@ -98,6 +139,147 @@ class ApplyRackLayoutTest(SimpleTestCase):
|
||||
device_model.objects.filter.assert_not_called()
|
||||
device.save.assert_not_called()
|
||||
|
||||
@patch("netbox_utilities.reorder_rack.DeviceRackPlacement")
|
||||
@patch("netbox_utilities.reorder_rack.get_permission_for_model", return_value="dcim.change_device")
|
||||
@patch("netbox_utilities.reorder_rack.Device")
|
||||
def test_saves_horizontal_only_move_for_partial_width_device(
|
||||
self,
|
||||
device_model,
|
||||
_get_permission,
|
||||
placement_model,
|
||||
):
|
||||
device = MagicMock(pk=10, position=Decimal(4), face="front")
|
||||
stored = SimpleNamespace(device_id=10, width=2, horizontal_position=1)
|
||||
device_model.objects.select_for_update.return_value.filter.return_value = [device]
|
||||
placement_model.objects.select_for_update.return_value.filter.return_value = [stored]
|
||||
user = MagicMock()
|
||||
user.has_perm.return_value = True
|
||||
placements = parse_device_placements(
|
||||
rack_layout(front=[{"id": 10, "x": 6, "y": 4}]),
|
||||
grid_columns=12,
|
||||
)
|
||||
|
||||
changed = apply_rack_layout(rack=SimpleNamespace(pk=5), placements=placements, user=user)
|
||||
|
||||
self.assertEqual(changed, [device])
|
||||
self.assertEqual(device._netbox_utilities_rack_width, 2)
|
||||
self.assertEqual(device._netbox_utilities_horizontal_position, 2)
|
||||
placement_model.objects.update_or_create.assert_called_once_with(
|
||||
device=device,
|
||||
defaults={"width": 2, "horizontal_position": 2},
|
||||
)
|
||||
|
||||
@patch("netbox_utilities.reorder_rack.DeviceRackPlacement")
|
||||
@patch("netbox_utilities.reorder_rack.get_permission_for_model", return_value="dcim.change_device")
|
||||
@patch("netbox_utilities.reorder_rack.Device")
|
||||
def test_legacy_payload_preserves_stored_horizontal_position(
|
||||
self,
|
||||
device_model,
|
||||
_get_permission,
|
||||
placement_model,
|
||||
):
|
||||
device = MagicMock(pk=10, position=Decimal(4), face="front")
|
||||
stored = SimpleNamespace(device_id=10, width=2, horizontal_position=2)
|
||||
device_model.objects.select_for_update.return_value.filter.return_value = [device]
|
||||
placement_model.objects.select_for_update.return_value.filter.return_value = [stored]
|
||||
user = MagicMock()
|
||||
user.has_perm.return_value = True
|
||||
placements = parse_device_placements(rack_layout(front=[{"id": 10, "x": 0, "y": 5}]))
|
||||
|
||||
changed = apply_rack_layout(rack=SimpleNamespace(pk=5), placements=placements, user=user)
|
||||
|
||||
self.assertEqual(changed, [device])
|
||||
placement_model.objects.update_or_create.assert_called_once_with(
|
||||
device=device,
|
||||
defaults={"width": 2, "horizontal_position": 2},
|
||||
)
|
||||
|
||||
|
||||
class ReorderRackFrontendTest(SimpleTestCase):
|
||||
def test_grid_y_matches_reorder_plugin_for_both_numbering_directions(self):
|
||||
self.assertEqual(reorder_grid_y(SimpleNamespace(u_height=42, desc_units=True), 4, 2), 6)
|
||||
self.assertEqual(reorder_grid_y(SimpleNamespace(u_height=42, desc_units=False), 4, 2), 74)
|
||||
|
||||
def test_width_adapter_runs_before_gridstack_and_preserves_original_save(self):
|
||||
script = (Path(__file__).parents[1] / "static" / "netbox_utilities" / "reorder-rack-width.js").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
self.assertIn("setAttribute(grid, 'gs-column', columns)", script)
|
||||
self.assertIn("setAttribute(item, 'gs-w', device.grid_width)", script)
|
||||
self.assertIn("setAttribute(item, 'gs-x', device.grid_x)", script)
|
||||
self.assertIn("grid.on('dragstop'", script)
|
||||
self.assertIn("grid.on('dropped'", script)
|
||||
self.assertIn("closest('#saveButton')", script)
|
||||
self.assertIn("X-NetBox-Utilities-Rack-Grid-Columns", script)
|
||||
self.assertNotIn("JSON.stringify", script)
|
||||
|
||||
@patch("netbox_utilities.reorder_rack.reorder_rack_width_enabled", return_value=True)
|
||||
@patch("netbox_utilities.reorder_rack.Rack")
|
||||
def test_malformed_partial_placement_fails_open(self, rack_model, _enabled):
|
||||
request = RequestFactory().get("/dcim/racks/5/reorder/")
|
||||
request.resolver_match = SimpleNamespace(kwargs={"pk": 5}, view_name="dcim:rack_reorder")
|
||||
request.user = MagicMock()
|
||||
rack_model.objects.restrict.return_value.filter.return_value.first.return_value = SimpleNamespace(pk=5)
|
||||
|
||||
with patch("netbox_utilities.reorder_rack.Device.objects.restrict", side_effect=AttributeError("stale")):
|
||||
self.assertIsNone(get_reorder_rack_width_data(request))
|
||||
|
||||
@patch("netbox_utilities.reorder_rack.reorder_rack_width_enabled", return_value=True)
|
||||
@patch("netbox_utilities.reorder_rack.get_permission_for_model", return_value="dcim.change_device")
|
||||
@patch("netbox_utilities.reorder_rack.Device")
|
||||
@patch("netbox_utilities.reorder_rack.Rack")
|
||||
def test_describes_partial_devices_for_reorder_grid(
|
||||
self,
|
||||
rack_model,
|
||||
device_model,
|
||||
_get_permission,
|
||||
_enabled,
|
||||
):
|
||||
request = RequestFactory().get("/dcim/racks/5/reorder/?view=labels-only")
|
||||
request.resolver_match = SimpleNamespace(kwargs={"pk": 5}, view_name="dcim:rack_reorder")
|
||||
request.user = MagicMock()
|
||||
request.user.has_perm.return_value = True
|
||||
rack = SimpleNamespace(pk=5, u_height=42, desc_units=False)
|
||||
rack_model.objects.restrict.return_value.filter.return_value.first.return_value = rack
|
||||
placement = SimpleNamespace(width=2, horizontal_position=2)
|
||||
device = SimpleNamespace(
|
||||
pk=10,
|
||||
name="Fritzbox",
|
||||
virtual_chassis=None,
|
||||
device_type=SimpleNamespace(
|
||||
u_height=1,
|
||||
is_full_depth=False,
|
||||
front_image=None,
|
||||
rear_image=None,
|
||||
),
|
||||
role=SimpleNamespace(color="abcdef"),
|
||||
face="front",
|
||||
position=Decimal(4),
|
||||
netbox_utilities_rack_placement=placement,
|
||||
)
|
||||
device_queryset = MagicMock()
|
||||
device_queryset.filter.return_value.select_related.return_value.order_by.return_value = [device]
|
||||
device_model.objects.restrict.return_value = device_queryset
|
||||
|
||||
with (
|
||||
patch("dcim.svg.racks.get_device_name", return_value="Fritzbox"),
|
||||
patch(
|
||||
"netbox.config.get_config",
|
||||
return_value=SimpleNamespace(RACK_ELEVATION_DEFAULT_UNIT_WIDTH=220),
|
||||
),
|
||||
patch("utilities.html.foreground_color", return_value="000000"),
|
||||
):
|
||||
result = get_reorder_rack_width_data(request)
|
||||
|
||||
self.assertEqual(result["columns"], 12)
|
||||
self.assertFalse(result["images"])
|
||||
self.assertTrue(result["labels"])
|
||||
self.assertEqual(
|
||||
{key: result["devices"][0][key] for key in ("id", "grid_x", "grid_width", "width", "horizontal_position")},
|
||||
{"id": 10, "grid_x": 6, "grid_width": 6, "width": 2, "horizontal_position": 2},
|
||||
)
|
||||
|
||||
|
||||
class ReorderRackPatchTest(SimpleTestCase):
|
||||
def test_patches_supported_plugin_only_once(self):
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from django.test import RequestFactory, SimpleTestCase
|
||||
|
||||
from netbox_utilities.topology_views import (
|
||||
get_topology_rack_width_data,
|
||||
is_topology_rack_elevation_request,
|
||||
serialize_topology_placement,
|
||||
topology_rack_width_enabled,
|
||||
)
|
||||
|
||||
|
||||
class TopologyViewsRackWidthTest(SimpleTestCase):
|
||||
def setUp(self):
|
||||
self.request = RequestFactory().get("/plugins/netbox_topology_views/rack-elevation/?rack_id=3")
|
||||
self.request.user = MagicMock()
|
||||
|
||||
def test_detects_only_topology_rack_elevation_route(self):
|
||||
self.request.resolver_match = SimpleNamespace(view_name="plugins:netbox_topology_views:rack_elevation")
|
||||
self.assertTrue(is_topology_rack_elevation_request(self.request))
|
||||
|
||||
self.request.resolver_match = SimpleNamespace(view_name="dcim:rack_list")
|
||||
self.assertFalse(is_topology_rack_elevation_request(self.request))
|
||||
|
||||
def test_skips_integration_when_optional_plugin_is_missing(self):
|
||||
self.request.resolver_match = SimpleNamespace(view_name="plugins:netbox_topology_views:rack_elevation")
|
||||
with (
|
||||
patch("netbox_utilities.topology_views.apps.is_installed", return_value=False),
|
||||
patch("netbox_utilities.topology_views.get_plugin_config", return_value=True),
|
||||
):
|
||||
self.assertFalse(topology_rack_width_enabled(self.request))
|
||||
|
||||
def test_skips_integration_when_admin_disabled_it(self):
|
||||
self.request.resolver_match = SimpleNamespace(view_name="plugins:netbox_topology_views:rack_elevation")
|
||||
with (
|
||||
patch("netbox_utilities.topology_views.apps.is_installed", return_value=True),
|
||||
patch("netbox_utilities.topology_views.get_plugin_config", return_value=False),
|
||||
):
|
||||
self.assertFalse(topology_rack_width_enabled(self.request))
|
||||
|
||||
def test_serializes_fractional_width_geometry_and_device_url(self):
|
||||
placement = SimpleNamespace(
|
||||
device_id=17,
|
||||
device=SimpleNamespace(get_absolute_url=lambda: "/dcim/devices/17/"),
|
||||
width=3,
|
||||
horizontal_position=2,
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
serialize_topology_placement(placement),
|
||||
{
|
||||
"device_id": 17,
|
||||
"url": "/dcim/devices/17/",
|
||||
"width": 3,
|
||||
"horizontal_position": 2,
|
||||
"left_percent": 33.33333333,
|
||||
"width_percent": 33.33333333,
|
||||
},
|
||||
)
|
||||
|
||||
@patch("netbox_utilities.topology_views.DeviceRackPlacement.objects")
|
||||
@patch("netbox_utilities.topology_views.topology_rack_width_enabled", return_value=True)
|
||||
def test_queries_only_permitted_devices_from_selected_racks(self, _enabled, placement_objects):
|
||||
self.request.resolver_match = SimpleNamespace(view_name="plugins:netbox_topology_views:rack_elevation")
|
||||
rack_queryset = MagicMock()
|
||||
rack_queryset.filter.return_value = rack_queryset
|
||||
rack_queryset.values.return_value = "selected-rack-query"
|
||||
rack_model = MagicMock()
|
||||
rack_model.objects.restrict.return_value = rack_queryset
|
||||
|
||||
device_queryset = MagicMock()
|
||||
device_queryset.filter.return_value = "permitted-device-query"
|
||||
device_model = MagicMock()
|
||||
device_model.objects.restrict.return_value = device_queryset
|
||||
|
||||
placement = SimpleNamespace(
|
||||
device_id=17,
|
||||
device=SimpleNamespace(get_absolute_url=lambda: "/dcim/devices/17/"),
|
||||
width=2,
|
||||
horizontal_position=2,
|
||||
)
|
||||
placement_queryset = MagicMock()
|
||||
placement_queryset.select_related.return_value.order_by.return_value = [placement]
|
||||
placement_objects.filter.return_value = placement_queryset
|
||||
|
||||
with patch.dict("sys.modules", {"dcim.models": SimpleNamespace(Device=device_model, Rack=rack_model)}):
|
||||
result = get_topology_rack_width_data(self.request)
|
||||
|
||||
rack_queryset.filter.assert_called_once_with(pk__in=["3"])
|
||||
device_queryset.filter.assert_called_once_with(
|
||||
rack_id__in="selected-rack-query",
|
||||
position__isnull=False,
|
||||
)
|
||||
placement_objects.filter.assert_called_once_with(device__in="permitted-device-query")
|
||||
self.assertEqual(result[0]["width"], 2)
|
||||
self.assertEqual(result[0]["horizontal_position"], 2)
|
||||
|
||||
def test_frontend_supports_live_view_and_all_rack_export_formats(self):
|
||||
script = (Path(__file__).parents[1] / "static" / "netbox_utilities" / "topology-rack-width.js").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
self.assertIn("netbox-utilities-partial-width", script)
|
||||
self.assertIn("horizontalGeometry", script)
|
||||
self.assertIn("buildRackSvg", script)
|
||||
self.assertIn("buildRackDrawio", script)
|
||||
self.assertIn("format === 'svg'", script)
|
||||
self.assertIn("format === 'drawio'", script)
|
||||
self.assertIn("canvas.toBlob", script)
|
||||
self.assertIn("event.stopImmediatePropagation()", script)
|
||||
self.assertIn("rackExportCompatible", script)
|
||||
self.assertIn("liveViewCompatible", script)
|
||||
self.assertLess(script.index("liveViewCompatible"), script.index("netbox-utilities-partial-width"))
|
||||
self.assertLess(script.index("exportGraphic(graphic"), script.index("event.preventDefault()"))
|
||||
|
||||
@patch("netbox_utilities.topology_views.topology_rack_width_enabled", return_value=True)
|
||||
def test_malformed_partial_placement_fails_open(self, _enabled):
|
||||
self.request.resolver_match = SimpleNamespace(view_name="plugins:netbox_topology_views:rack_elevation")
|
||||
with patch("dcim.models.Rack.objects.restrict", side_effect=AttributeError("stale")):
|
||||
self.assertIsNone(get_topology_rack_width_data(self.request))
|
||||
|
||||
def test_partial_width_css_uses_topology_device_slot_geometry(self):
|
||||
stylesheet = (Path(__file__).parents[1] / "static" / "netbox_utilities" / "netbox_utilities.css").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
self.assertIn(".rack-device.netbox-utilities-partial-width", stylesheet)
|
||||
self.assertIn("--netbox-utilities-rack-device-left", stylesheet)
|
||||
self.assertIn("--netbox-utilities-rack-device-width", stylesheet)
|
||||
@@ -0,0 +1,80 @@
|
||||
import logging
|
||||
|
||||
from django.apps import apps
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.db import OperationalError, ProgrammingError
|
||||
from netbox.plugins import get_plugin_config
|
||||
|
||||
from .models import DeviceRackPlacement
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TOPOLOGY_RACK_ELEVATION_VIEW = "plugins:netbox_topology_views:rack_elevation"
|
||||
|
||||
|
||||
def is_topology_rack_elevation_request(request):
|
||||
resolver_match = getattr(request, "resolver_match", None)
|
||||
return getattr(resolver_match, "view_name", None) == TOPOLOGY_RACK_ELEVATION_VIEW
|
||||
|
||||
|
||||
def topology_rack_width_enabled(request):
|
||||
"""Return whether the optional Topology Views rack integration applies."""
|
||||
return bool(
|
||||
is_topology_rack_elevation_request(request)
|
||||
and get_plugin_config("netbox_utilities", "topology_views_rack_width_enabled")
|
||||
and apps.is_installed("netbox_topology_views")
|
||||
)
|
||||
|
||||
|
||||
def serialize_topology_placement(placement):
|
||||
width = int(placement.width)
|
||||
horizontal_position = int(placement.horizontal_position)
|
||||
return {
|
||||
"device_id": placement.device_id,
|
||||
"url": placement.device.get_absolute_url(),
|
||||
"width": width,
|
||||
"horizontal_position": horizontal_position,
|
||||
"left_percent": round((horizontal_position - 1) / width * 100, 8),
|
||||
"width_percent": round(100 / width, 8),
|
||||
}
|
||||
|
||||
|
||||
def get_topology_rack_width_data(request):
|
||||
"""Return permitted partial-width devices shown by Topology Views."""
|
||||
if not topology_rack_width_enabled(request) or not request.GET:
|
||||
return None
|
||||
|
||||
from dcim.models import Device, Rack
|
||||
|
||||
try:
|
||||
racks = Rack.objects.restrict(request.user, "view")
|
||||
selected_racks = request.GET.getlist("rack_id")
|
||||
if selected_racks:
|
||||
racks = racks.filter(pk__in=selected_racks)
|
||||
else:
|
||||
selected_sites = request.GET.getlist("site_id")
|
||||
selected_locations = request.GET.getlist("location_id")
|
||||
if selected_sites:
|
||||
racks = racks.filter(site_id__in=selected_sites)
|
||||
if selected_locations:
|
||||
racks = racks.filter(location_id__in=selected_locations)
|
||||
|
||||
devices = Device.objects.restrict(request.user, "view").filter(
|
||||
rack_id__in=racks.values("pk"),
|
||||
position__isnull=False,
|
||||
)
|
||||
placements = (
|
||||
DeviceRackPlacement.objects.filter(device__in=devices).select_related("device").order_by("device_id")
|
||||
)
|
||||
return [serialize_topology_placement(placement) for placement in placements]
|
||||
except (
|
||||
AttributeError,
|
||||
ObjectDoesNotExist,
|
||||
OperationalError,
|
||||
ProgrammingError,
|
||||
TypeError,
|
||||
ValueError,
|
||||
):
|
||||
# Keep NetBox usable while plugin migrations are being installed.
|
||||
logger.warning("Could not load partial rack widths for NetBox Topology Views", exc_info=True)
|
||||
return None
|
||||
+1
-1
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user