feat: integrate rack widths across plugins
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# NetBox Utilities
|
# 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.
|
- 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.
|
- 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 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`.
|
- 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.
|
- 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 Bilder in einem Schritt im Bilder-Tab eines Objekts hochladen.
|
||||||
- Mehrere Module desselben Typs in einem Schritt in freie Modulschächte einbauen.
|
- Mehrere Module desselben Typs in einem Schritt in freie Modulschächte einbauen.
|
||||||
- Optionale Mehrfachspeicherung für verschobene Geräte aus NetBox Reorder Rack.
|
- 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`
|
- NetBox `>=4.6.5,<4.7`
|
||||||
- Python `>=3.12`
|
- Python `>=3.12`
|
||||||
- optional: NetBox Reorder Rack `1.1.4`
|
- 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.
|
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
|
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
|
```bash
|
||||||
/opt/netbox/venv/bin/pip install --upgrade --force-reinstall \
|
/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.
|
Alternativ kann hinter dem `@` die vollständige Commit-ID stehen.
|
||||||
@@ -69,6 +72,7 @@ PLUGINS_CONFIG = {
|
|||||||
"netbox_utilities": {
|
"netbox_utilities": {
|
||||||
"navigation_customization_enabled": True,
|
"navigation_customization_enabled": True,
|
||||||
"reorder_rack_bulk_save_enabled": True,
|
"reorder_rack_bulk_save_enabled": True,
|
||||||
|
"topology_views_rack_width_enabled": True,
|
||||||
"tenant_filter_enabled": True,
|
"tenant_filter_enabled": True,
|
||||||
"tenant_required": True,
|
"tenant_required": True,
|
||||||
},
|
},
|
||||||
@@ -135,9 +139,9 @@ empfohlen.
|
|||||||
|
|
||||||
### Mehrere Geräte nebeneinander in derselben HE
|
### Mehrere Geräte nebeneinander in derselben HE
|
||||||
|
|
||||||
Auf der normalen **Bearbeitungsseite eines Geräts** stehen direkt nach
|
Auf der normalen Seite zum **Anlegen oder Bearbeiten eines Geräts** stehen
|
||||||
**Position** zwei neue optionale Felder zur Verfügung. Dafür wird kein
|
direkt nach **Position** zwei neue optionale Felder zur Verfügung. Dafür wird
|
||||||
separates Plugin-Menü benötigt:
|
kein separates Plugin-Menü benötigt:
|
||||||
|
|
||||||
- **Rackbreite**: volle, halbe, Drittel- oder Viertelbreite;
|
- **Rackbreite**: volle, halbe, Drittel- oder Viertelbreite;
|
||||||
- **Breitenposition**: Position 1 bis 4, von links gezählt.
|
- **Breitenposition**: Position 1 bis 4, von links gezählt.
|
||||||
@@ -169,6 +173,44 @@ wiederhergestellt.
|
|||||||
Die Zusatzfelder werden derzeit im NetBox-Webformular gepflegt. REST- oder
|
Die Zusatzfelder werden derzeit im NetBox-Webformular gepflegt. REST- oder
|
||||||
CSV-Vorgänge ohne diese Felder behandeln neue Geräte als volle Rackbreite.
|
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
|
### Front- und Rearports von Patchpaneln automatisch verknüpfen
|
||||||
|
|
||||||
Geräte, deren NetBox-Geräterolle `Patchpanel` heißt, werden automatisch als
|
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.
|
erweitert NetBox Utilities dessen vorhandenen Speichervorgang automatisch.
|
||||||
In der Drag-and-drop-Ansicht können mehrere Geräte verschoben oder miteinander
|
In der Drag-and-drop-Ansicht können mehrere Geräte verschoben oder miteinander
|
||||||
getauscht und anschließend gemeinsam über **Save** gespeichert werden.
|
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-
|
Die optionale Abhängigkeit wird separat installiert und für spätere NetBox-
|
||||||
Updates in `/opt/netbox/local_requirements.txt` aufgenommen:
|
Updates in `/opt/netbox/local_requirements.txt` aufgenommen:
|
||||||
@@ -214,8 +263,8 @@ PLUGINS = [
|
|||||||
```
|
```
|
||||||
|
|
||||||
NetBox Utilities verändert keine Dateien des Reorder-Plugins. Stattdessen wird
|
NetBox Utilities verändert keine Dateien des Reorder-Plugins. Stattdessen wird
|
||||||
dessen API beim Start kompatibel erweitert. Beim Speichern werden alle
|
dessen Oberfläche und API beim Start kompatibel erweitert. Beim Speichern
|
||||||
betroffenen Geräte und das Rack gesperrt, die alten Positionen gemeinsam
|
werden alle betroffenen Geräte und das Rack gesperrt, die alten Positionen gemeinsam
|
||||||
freigegeben und danach sämtliche Zielpositionen mit der normalen
|
freigegeben und danach sämtliche Zielpositionen mit der normalen
|
||||||
NetBox-Modellvalidierung geprüft. Der Vorgang läuft in einer Transaktion: Bei
|
NetBox-Modellvalidierung geprüft. Der Vorgang läuft in einer Transaktion: Bei
|
||||||
einem Platz-, Validierungs- oder Berechtigungsfehler bleibt das gesamte Rack
|
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
|
verändert und erzeugen lediglich einen Hinweis im NetBox-Log. Das
|
||||||
Reorder-Projekt selbst weist derzeit offiziell nur Kompatibilität bis NetBox
|
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
|
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
|
### Mehrere Bilder hochladen
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from netbox.plugins import PluginConfig, get_plugin_config
|
from netbox.plugins import PluginConfig, get_plugin_config
|
||||||
|
|
||||||
__version__ = "0.8.8"
|
__version__ = "0.9.0"
|
||||||
|
|
||||||
|
|
||||||
class NetBoxUtilitiesConfig(PluginConfig):
|
class NetBoxUtilitiesConfig(PluginConfig):
|
||||||
@@ -15,6 +15,7 @@ class NetBoxUtilitiesConfig(PluginConfig):
|
|||||||
default_settings = {
|
default_settings = {
|
||||||
"navigation_customization_enabled": True,
|
"navigation_customization_enabled": True,
|
||||||
"reorder_rack_bulk_save_enabled": True,
|
"reorder_rack_bulk_save_enabled": True,
|
||||||
|
"topology_views_rack_width_enabled": True,
|
||||||
"tenant_filter_enabled": True,
|
"tenant_filter_enabled": True,
|
||||||
"tenant_required": True,
|
"tenant_required": True,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import decimal
|
import decimal
|
||||||
|
import logging
|
||||||
from contextvars import ContextVar
|
from contextvars import ContextVar
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from fractions import Fraction
|
from fractions import Fraction
|
||||||
@@ -11,6 +12,8 @@ from django.db.models.signals import post_save, pre_save
|
|||||||
|
|
||||||
from .models import DeviceRackPlacement
|
from .models import DeviceRackPlacement
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
FULL_WIDTH = 1
|
FULL_WIDTH = 1
|
||||||
WIDTH_CHOICES = (
|
WIDTH_CHOICES = (
|
||||||
(FULL_WIDTH, "Volle Rackbreite"),
|
(FULL_WIDTH, "Volle Rackbreite"),
|
||||||
@@ -432,14 +435,22 @@ def _install_device_form():
|
|||||||
RackWidthDeviceEditView.__module__ = __name__
|
RackWidthDeviceEditView.__module__ = __name__
|
||||||
RackWidthDeviceEditView.__qualname__ = "RackWidthDeviceEditView"
|
RackWidthDeviceEditView.__qualname__ = "RackWidthDeviceEditView"
|
||||||
|
|
||||||
|
replaced_views = set()
|
||||||
for view_config in registry["views"]["dcim"]["device"]:
|
for view_config in registry["views"]["dcim"]["device"]:
|
||||||
if view_config["name"] == "edit":
|
if view_config["name"] in {"add", "edit"}:
|
||||||
view_config["view"] = RackWidthDeviceEditView
|
view_config["view"] = RackWidthDeviceEditView
|
||||||
break
|
replaced_views.add(view_config["name"])
|
||||||
else:
|
if replaced_views != {"add", "edit"}:
|
||||||
raise RuntimeError("Die NetBox-Gerätebearbeitung ist nicht in der View-Registry registriert.")
|
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.form = RackWidthDeviceForm
|
||||||
|
DeviceEditView.template_name = RackWidthDeviceEditView.template_name
|
||||||
|
DeviceEditView.htmx_template_name = RackWidthDeviceEditView.htmx_template_name
|
||||||
DeviceEditView._netbox_utilities_rack_width_installed = True
|
DeviceEditView._netbox_utilities_rack_width_installed = True
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,19 +5,25 @@ from importlib import import_module
|
|||||||
|
|
||||||
from dcim.models import Device, Rack
|
from dcim.models import Device, Rack
|
||||||
from django.apps import apps
|
from django.apps import apps
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ObjectDoesNotExist, ValidationError
|
||||||
from django.db import transaction
|
from django.db import OperationalError, ProgrammingError, transaction
|
||||||
from django.http import Http404
|
from django.http import Http404
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
|
from netbox.plugins import get_plugin_config
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.exceptions import PermissionDenied
|
from rest_framework.exceptions import PermissionDenied
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from utilities.permissions import get_permission_for_model
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
SUPPORTED_REORDER_RACK_VERSIONS = {"1.1.4"}
|
SUPPORTED_REORDER_RACK_VERSIONS = {"1.1.4"}
|
||||||
PATCH_MARKER = "_netbox_utilities_bulk_save"
|
PATCH_MARKER = "_netbox_utilities_bulk_save"
|
||||||
|
GRID_COLUMNS = 12
|
||||||
|
REORDER_RACK_VIEW = "dcim:rack_reorder"
|
||||||
|
|
||||||
|
|
||||||
class RackLayoutError(Exception):
|
class RackLayoutError(Exception):
|
||||||
@@ -29,9 +35,31 @@ class DevicePlacement:
|
|||||||
device_id: int
|
device_id: int
|
||||||
position: Decimal | None
|
position: Decimal | None
|
||||||
face: str
|
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 = []
|
placements = []
|
||||||
seen_device_ids = set()
|
seen_device_ids = set()
|
||||||
|
|
||||||
@@ -53,6 +81,7 @@ def parse_device_placements(layout):
|
|||||||
|
|
||||||
if section == "other":
|
if section == "other":
|
||||||
position = None
|
position = None
|
||||||
|
grid_x = None
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
position = Decimal(str(item["y"]))
|
position = Decimal(str(item["y"]))
|
||||||
@@ -61,7 +90,13 @@ def parse_device_placements(layout):
|
|||||||
if not position.is_finite():
|
if not position.is_finite():
|
||||||
raise RackLayoutError(f"Gerät {device_id} besitzt keine gültige Rack-Position.")
|
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
|
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))
|
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}.")
|
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 = [
|
changed = [
|
||||||
(devices[placement.device_id], placement)
|
(device, placement, target_width, target_horizontal_position)
|
||||||
for placement in placements
|
for (
|
||||||
|
device,
|
||||||
|
placement,
|
||||||
|
target_width,
|
||||||
|
target_horizontal_position,
|
||||||
|
current_width,
|
||||||
|
current_horizontal_position,
|
||||||
|
) in target_placements
|
||||||
if (
|
if (
|
||||||
devices[placement.device_id].position != placement.position
|
device.position != placement.position
|
||||||
or devices[placement.device_id].face != placement.face
|
or device.face != placement.face
|
||||||
|
or current_width != target_width
|
||||||
|
or current_horizontal_position != target_horizontal_position
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
if not changed:
|
if not changed:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
permission = get_permission_for_model(Device, "change")
|
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):
|
if not user.has_perm(permission, obj=device):
|
||||||
raise PermissionDenied(f"Keine Berechtigung zum Verschieben von {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()
|
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="")
|
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.position = placement.position
|
||||||
device.face = placement.face
|
device.face = placement.face
|
||||||
|
stage_width_position(device, width, horizontal_position)
|
||||||
device.full_clean()
|
device.full_clean()
|
||||||
device.save()
|
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):
|
def _validation_error_message(error):
|
||||||
@@ -117,6 +198,119 @@ def _validation_error_message(error):
|
|||||||
return "; ".join(str(message) for message in error.messages)
|
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):
|
def bulk_reorder_update(self, request, pk=None):
|
||||||
serializer = self.serializer_class(data=request.data)
|
serializer = self.serializer_class(data=request.data)
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
@@ -128,7 +322,12 @@ def bulk_reorder_update(self, request, pk=None):
|
|||||||
if layout["rack_id"] != rack.pk:
|
if layout["rack_id"] != rack.pk:
|
||||||
raise RackLayoutError("Die Rack-ID der Anfrage stimmt nicht mit dem Ziel-Rack überein.")
|
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)
|
changed_devices = apply_rack_layout(rack=rack, placements=placements, user=request.user)
|
||||||
if not changed_devices:
|
if not changed_devices:
|
||||||
return Response(
|
return Response(
|
||||||
|
|||||||
@@ -29,6 +29,13 @@
|
|||||||
white-space: nowrap;
|
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 {
|
.netbox-utilities-sidebar-resizer {
|
||||||
display: none;
|
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_preferences,
|
||||||
normalize_sidebar_width,
|
normalize_sidebar_width,
|
||||||
)
|
)
|
||||||
|
from .reorder_rack import get_reorder_rack_width_data
|
||||||
from .runtime import navigation_customization_enabled, tenant_filter_enabled
|
from .runtime import navigation_customization_enabled, tenant_filter_enabled
|
||||||
|
from .topology_views import get_topology_rack_width_data
|
||||||
|
|
||||||
|
|
||||||
class UtilitiesGlobalContent(PluginTemplateExtension):
|
class UtilitiesGlobalContent(PluginTemplateExtension):
|
||||||
def head(self):
|
def head(self):
|
||||||
request = self.context["request"]
|
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 = {
|
preference_data = {
|
||||||
"order": [],
|
"order": [],
|
||||||
"hidden": [],
|
"hidden": [],
|
||||||
@@ -56,6 +60,8 @@ class UtilitiesGlobalContent(PluginTemplateExtension):
|
|||||||
},
|
},
|
||||||
"navigation_enabled": request.user.is_authenticated and navigation_customization_enabled(),
|
"navigation_enabled": request.user.is_authenticated and navigation_customization_enabled(),
|
||||||
"navigation_preferences": preference_data,
|
"navigation_preferences": preference_data,
|
||||||
|
"reorder_rack_width_data": reorder_rack_width_data,
|
||||||
|
"topology_rack_width_data": topology_rack_width_data,
|
||||||
"asset_version": __version__,
|
"asset_version": __version__,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,3 +8,11 @@
|
|||||||
{{ navigation_preferences|json_script:"netbox-utilities-navigation-data" }}
|
{{ navigation_preferences|json_script:"netbox-utilities-navigation-data" }}
|
||||||
<script src="{% static 'netbox_utilities/navigation.js' %}?v={{ asset_version }}" defer></script>
|
<script src="{% static 'netbox_utilities/navigation.js' %}?v={{ asset_version }}" defer></script>
|
||||||
{% endif %}
|
{% 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):
|
def test_device_edit_view_uses_explicit_rack_width_form(self):
|
||||||
from netbox.registry import registry
|
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(device_view.__name__, "RackWidthDeviceEditView")
|
||||||
self.assertEqual(edit_view.form.__name__, "RackWidthDeviceForm")
|
self.assertEqual(device_view.form.__name__, "RackWidthDeviceForm")
|
||||||
self.assertEqual(edit_view.template_name, "netbox_utilities/device_edit.html")
|
self.assertEqual(device_view.template_name, "netbox_utilities/device_edit.html")
|
||||||
self.assertEqual(edit_view.htmx_template_name, "netbox_utilities/device_edit_form.html")
|
self.assertEqual(device_view.htmx_template_name, "netbox_utilities/device_edit_form.html")
|
||||||
self.assertIn("utilities_rack_width", edit_view.form.base_fields)
|
self.assertIn("utilities_rack_width", device_view.form.base_fields)
|
||||||
self.assertIn("utilities_horizontal_position", edit_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):
|
def test_resolved_device_edit_page_renders_rack_width_fields(self):
|
||||||
from dcim.models import Device
|
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(width), html.index(horizontal_position))
|
||||||
self.assertLess(html.index(horizontal_position), html.index(latitude))
|
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):
|
def test_bound_device_form_clean_handles_netbox_clean_returning_none(self):
|
||||||
from dcim.models import Device
|
from dcim.models import Device
|
||||||
from netbox.registry import registry
|
from netbox.registry import registry
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
from django.test import SimpleTestCase
|
from django.test import RequestFactory, SimpleTestCase
|
||||||
from rest_framework.exceptions import PermissionDenied
|
from rest_framework.exceptions import PermissionDenied
|
||||||
|
|
||||||
from netbox_utilities.reorder_rack import (
|
from netbox_utilities.reorder_rack import (
|
||||||
RackLayoutError,
|
RackLayoutError,
|
||||||
apply_rack_layout,
|
apply_rack_layout,
|
||||||
bulk_reorder_update,
|
bulk_reorder_update,
|
||||||
|
get_reorder_rack_width_data,
|
||||||
|
grid_dimensions,
|
||||||
|
horizontal_position_from_grid_x,
|
||||||
install_reorder_rack_bulk_save,
|
install_reorder_rack_bulk_save,
|
||||||
parse_device_placements,
|
parse_device_placements,
|
||||||
|
reorder_grid_y,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -32,14 +37,41 @@ class RackLayoutParsingTest(SimpleTestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(
|
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"),
|
(10, Decimal(4), "front", None),
|
||||||
(11, Decimal("8.5"), "front"),
|
(11, Decimal("8.5"), "front", None),
|
||||||
(12, 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):
|
def test_rejects_duplicate_devices(self):
|
||||||
with self.assertRaisesMessage(RackLayoutError, "mehrfach"):
|
with self.assertRaisesMessage(RackLayoutError, "mehrfach"):
|
||||||
parse_device_placements(
|
parse_device_placements(
|
||||||
@@ -55,9 +87,15 @@ class RackLayoutParsingTest(SimpleTestCase):
|
|||||||
|
|
||||||
|
|
||||||
class ApplyRackLayoutTest(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.get_permission_for_model", return_value="dcim.change_device")
|
||||||
@patch("netbox_utilities.reorder_rack.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 = []
|
events = []
|
||||||
first = MagicMock(pk=10, position=Decimal(1), face="front")
|
first = MagicMock(pk=10, position=Decimal(1), face="front")
|
||||||
second = MagicMock(pk=11, position=Decimal(2), 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.select_for_update.return_value.filter.return_value = [first, second]
|
||||||
device_model.objects.filter.return_value.update.side_effect = lambda **_kwargs: events.append("clear")
|
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 = MagicMock()
|
||||||
user.has_perm.return_value = True
|
user.has_perm.return_value = True
|
||||||
placements = parse_device_placements(rack_layout(front=[{"id": 10, "y": 2}, {"id": 11, "y": 1}]))
|
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((first.position, first.face), (Decimal(2), "front"))
|
||||||
self.assertEqual((second.position, second.face), (Decimal(1), "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.get_permission_for_model", return_value="dcim.change_device")
|
||||||
@patch("netbox_utilities.reorder_rack.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 = MagicMock(pk=10, position=Decimal(1), face="front")
|
||||||
device_model.objects.select_for_update.return_value.filter.return_value = [device]
|
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 = MagicMock()
|
||||||
user.has_perm.return_value = False
|
user.has_perm.return_value = False
|
||||||
placements = parse_device_placements(rack_layout(front=[{"id": 10, "y": 2}]))
|
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_model.objects.filter.assert_not_called()
|
||||||
device.save.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):
|
class ReorderRackPatchTest(SimpleTestCase):
|
||||||
def test_patches_supported_plugin_only_once(self):
|
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]
|
[project]
|
||||||
name = "netbox-utilities"
|
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"
|
description = "Navigation, tenant utilities, partial-width rack devices, bulk uploads, and rack reordering for NetBox 4.6"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
|
|||||||
Reference in New Issue
Block a user