Compare commits

...
27 Commits
Author SHA1 Message Date
MrBlake f3d658b52b feat: filter connection VLANs by group 2026-08-25 12:11:18 +02:00
MrBlake d245ae0f79 ui: place connection VLANs below cable B side 2026-08-25 12:03:45 +02:00
MrBlake 4421000409 fix: ignore reverse relations during tenant autofill 2026-08-19 12:40:17 +02:00
MrBlake bd748ef985 feat: assign VLANs to network connections 2026-08-19 12:24:40 +02:00
MrBlake 1c6708216c fix: preserve shared rack units during import 2026-08-19 10:53:44 +02:00
MrBlake 7216dad2b4 fix: separate rack width import locks 2026-08-19 10:43:33 +02:00
MrBlake 639dc958a5 feat: preserve rack widths in netbox export 2026-08-19 10:33:17 +02:00
MrBlake 839b076d94 fix: preserve reorder drag width and images 2026-08-13 16:10:47 +02:00
MrBlake 79a071597c fix: restore visible rack integration widgets 2026-08-13 11:21:48 +02:00
MrBlake e42580d651 fix: bind rack integrations to rendered devices 2026-08-13 10:51:59 +02:00
MrBlake 270d821219 fix: render rack integrations server-side 2026-08-13 10:15:13 +02:00
MrBlake 33490d40c4 fix: preserve native reorder rack devices 2026-08-13 09:58:34 +02:00
MrBlake a5f8cb62c7 fix: restore populated rack and reorder views 2026-08-13 09:37:59 +02:00
MrBlake bc2024ac13 fix: render all devices sharing rack units 2026-08-13 09:09:55 +02:00
MrBlake a0358136b2 fix: restore rack and reorder width views 2026-08-13 08:35:21 +02:00
MrBlake 70825c4040 feat: integrate rack widths across plugins 2026-08-12 17:12:19 +02:00
MrBlake ec67e6b7d0 revert: defer rack images to NetBox 2026-08-12 16:02:43 +02:00
MrBlake 4ee78ae4fe fix: update rack width controls and images 2026-08-12 15:57:22 +02:00
MrBlake c663069825 fix: render partial-width devices in rack SVG 2026-08-12 15:41:40 +02:00
MrBlake 7dc9b4d91d fix: handle NetBox device form clean result 2026-08-12 15:24:01 +02:00
MrBlake c38ab30acc fix: render rack width fields in device template 2026-08-12 14:28:35 +02:00
MrBlake 008a69ed79 fix: render rack width fields in grouped device forms 2026-08-12 13:19:58 +02:00
MrBlake 37fc55449f fix: bind rack width form to device edit route 2026-08-12 12:43:54 +02:00
MrBlake 2a7459c460 fix: expose rack width fields on device edit form 2026-08-12 11:30:43 +02:00
MrBlake a504341442 feat: support partial-width rack devices 2026-08-12 10:38:54 +02:00
MrBlake 267a78d58d fix: skip patchpanel automation on edits 2026-08-06 16:07:47 +02:00
MrBlake 66d13292f2 fix: remove existing patchpanel backfill 2026-08-06 16:03:58 +02:00
44 changed files with 4933 additions and 239 deletions
+1
View File
@@ -4,4 +4,5 @@ __pycache__/
.ruff_cache/
build/
dist/
.testvenv/
*.egg-info/
+264 -24
View File
@@ -1,23 +1,42 @@
# NetBox Utilities
Plugin für **NetBox 4.6.5** mit acht Funktionen:
Plugin für **NetBox 4.6.5 bis 4.6.8** mit zwölf 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.
- Optional verpflichtende Mandantenzuordnung für alle mandantenfähigen Objekte.
- 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.
- Rackbreiten bleiben beim optionalen NetBox-Export und -Import erhalten.
- Kabel- und Funkverbindungen können direkt einem oder mehreren VLANs zugeordnet werden.
Die Navigationseinstellungen sind benutzerbezogen. Die aktive Mandanten- oder Gruppenauswahl wird in der jeweiligen Browser-Session gespeichert.
Ab Version `0.10.1` ignoriert die automatische Mandantenermittlung
mehrwertige Reverse-Relationen. Das behebt unter NetBox 4.6.8 insbesondere den
Fehler `'RelatedManager' object has no attribute '_meta'` beim Öffnen der
Seite zum Anlegen einer VLAN-Gruppe.
Ab Version `0.10.2` befindet sich die VLAN-Auswahl beim Anlegen und Bearbeiten
eines Kabels direkt im Abschnitt **B-Seite**, unterhalb des B-seitigen
Verbindungsendes.
Ab Version `0.10.3` kann dort optional eine VLAN-Gruppe ausgewählt werden. Sie
dient als dynamischer Filter für die VLAN-Mehrfachauswahl und wird nicht als
zusätzliche Eigenschaft der Verbindung gespeichert.
## Kompatibilität
- NetBox `>=4.6.5,<4.7`
- Python `>=3.12`
- optional: NetBox Reorder Rack `1.1.4`
- optional: NetBox-Export `0.3.11`
- 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.
@@ -31,11 +50,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.7.2"
"git+https://git.mrblake.cc/MrBlake/Netbox-Utilities.git@COMMIT-ID"
```
Alternativ kann hinter dem `@` die vollständige Commit-ID stehen.
@@ -67,7 +87,9 @@ PLUGINS = [
PLUGINS_CONFIG = {
"netbox_utilities": {
"navigation_customization_enabled": True,
"connection_vlans_enabled": True,
"reorder_rack_bulk_save_enabled": True,
"topology_views_rack_width_enabled": True,
"tenant_filter_enabled": True,
"tenant_required": True,
},
@@ -100,6 +122,88 @@ sudo systemctl restart netbox netbox-rq
Nach Änderungen an JavaScript oder CSS kann ein Hard-Reload des Browsers mit
`Strg+F5` erforderlich sein.
Ab Version `0.9.3` werden Rack-SVG und Reorder direkt aus allen Geräten des
Racks aufgebaut. Dadurch können mehrere Geräte derselben HE nicht mehr durch
NetBox' native Ein-Gerät-pro-HE-Darstellung verloren gehen. Die Adapter werden
auch dann eingebunden, wenn keine Teilbreitengeräte gefunden wurden. Nach
diesem Update ist `collectstatic` deshalb zwingend erforderlich;
die Dateien `topology-rack-width.js` und `reorder-rack-width.js` müssen unter
`/opt/netbox/netbox/static/netbox_utilities/` vorhanden sein.
Reorder ersetzt seine nativen Gerätekacheln erst, nachdem ein vollständiger
und zur JavaScript-Version passender Datensatz geprüft wurde. Bei einer
gemischten Installation aus altem Python-Code und neuen statischen Dateien
bleibt deshalb die native Ansicht erhalten, statt ein leeres Rack zu zeigen.
Ab Version `0.9.4` werden vorhandene Reorder-Kacheln außerdem direkt auf das
12-Spalten-Raster erweitert und nicht mehr vorsorglich neu erzeugt. Nur ein
zweites, von NetBox' Ein-Gerät-pro-HE-Darstellung unterschlagenes Gerät wird
aus den Plugin-Daten ergänzt. Das verhindert browserabhängige leere Racks.
Ab Version `0.9.5` rendert das Plugin das vollständige 12-Spalten-Rack bereits
serverseitig in die Reorder-Seite. Die sichtbaren Geräte sind dadurch nicht
mehr vom Ladezeitpunkt des Browseradapters abhängig. Für die optionale
Topology-Rack-Ansicht wird die Teilbreitengeometrie zusätzlich als
serverseitiges CSS ausgegeben; JavaScript wird dort nur noch für Exporte und
ergänzende Metadaten benötigt.
Ab Version `0.9.6` verwendet Reorder direkt den bereits durch seinen View
autorisierten Rack-Datensatz. Die Topology-Rack-Ansicht leitet ihre Breiten
ausschließlich aus den Geräten ab, die der Topology-View tatsächlich in seine
HTML-Antwort geschrieben hat. Zusätzliche Berechtigungsabfragen können die
beiden Ansichten dadurch nicht mehr fälschlich leeren.
Ab Version `0.9.7` enthält Reorder eigene 12-Spalten-CSS-Regeln. Diese beheben
die Inkompatibilität zwischen GridStack 10 aus Reorder Rack `1.1.4` und dem
GridStack-11-Stylesheet von NetBox 4.6.7, durch die Gerätekacheln mit einer
berechneten Breite von `0px` unsichtbar waren. Topology-Breiten werden außerdem
nach dem vollständigen Rendern zentral auf die HTTP-Antwort angewendet und
zusätzlich direkt in die vorhandenen Geräte-Styles geschrieben.
Ab Version `0.9.8` behalten Geräte beim Ziehen in Reorder ihre tatsächliche
Rackbreite. Die 12-Spalten-Regeln lassen dazu GridStacks temporären
Pixelkoordinaten während des Ziehens Vorrang, statt eine Prozentbreite auf das
Browserfenster anzuwenden. Die serverseitig aufgebauten Reorder-Kacheln zeigen
außerdem wieder die Front- und Rückseitenbilder des Gerätetyps entsprechend
der gewählten Ansicht an.
Bereits vorhandene Geräte, die dieselbe HE und Rackseite belegen, aber noch
keine Plugin-Platzierungszeile besitzen, werden in Rack-SVG, Reorder und der
Topology-Rack-Ansicht ohne Datenbankänderung gleichmäßig nebeneinander
dargestellt. Explizit gespeicherte Rackbreiten und Breitenpositionen haben
stets Vorrang vor dieser Anzeige-Ableitung.
Die Installation lässt sich anschließend mit diesen Befehlen kontrollieren:
```bash
/opt/netbox/venv/bin/python -c \
"import netbox_utilities; print(netbox_utilities.__version__, netbox_utilities.__file__)"
test -f /opt/netbox/netbox/static/netbox_utilities/reorder-rack-width.js
test -f /opt/netbox/netbox/static/netbox_utilities/topology-rack-width.js
grep -q "schema_version !== 3" \
/opt/netbox/netbox/static/netbox_utilities/reorder-rack-width.js
cd /opt/netbox/netbox
/opt/netbox/venv/bin/python manage.py shell -c \
"from django.urls import reverse; print(reverse('dcim:rack_reorder', kwargs={'pk': 1})); print(reverse('plugins:netbox_topology_views:rack_elevation'))"
```
Die letzten beiden Routen müssen auflösbar sein. Die Topology-Rack-Route ist
im getesteten MrBlake-Develop-Stand `86a8daf45ef380d57ac73b461dbc47a16bb831ba`
enthalten, aber nicht im gleich bezeichneten öffentlichen Tag `v4.5.1`.
Für Rack `2` kann zusätzlich geprüft werden, welche gespeicherten Breiten das
Plugin findet. Zwei Geräte derselben HE dürfen dabei unterschiedliche oder
noch leere Plugin-Werte besitzen; leere Werte werden ab `0.9.3` für die
Darstellung abgeleitet:
```bash
cd /opt/netbox/netbox
/opt/netbox/venv/bin/python manage.py shell -c "
from dcim.models import Device
print(list(Device.objects.filter(rack_id=2).order_by('position', 'id').values(
'id', 'name', 'position', 'face',
'netbox_utilities_rack_placement__width',
'netbox_utilities_rack_placement__horizontal_position',
)))
"
```
## Aktualisierung
Bei einer Installation aus dem `main`-Branch wird das Paket erneut aus Gitea
@@ -132,6 +236,145 @@ empfohlen.
## Verwendung
### VLANs direkt an Verbindungen dokumentieren
Ab Version `0.10.0` erscheint beim normalen **Anlegen und Bearbeiten** einer
Kabelverbindung oder Funkverbindung das optionale Feld **VLANs der
Verbindung**. Darin können ein oder mehrere bestehende NetBox-VLANs ausgewählt
werden. Die Auswahl wird außerdem auf der Detailseite der Verbindung
angezeigt.
Die Zuordnung dokumentiert, welche VLANs über die jeweilige Verbindung
transportiert werden. Sie verändert bewusst nicht automatisch die nativen
Felder **Untagged VLAN** und **Tagged VLANs** der beteiligten Interfaces, da
beide Enden unterschiedliche Interface-Modi besitzen können. Diese bleiben
weiterhin die technische Konfiguration der einzelnen Interfaces.
Die Funktion gilt für:
- physische NetBox-Kabel, einschließlich Verbindungen über Patchfelder;
- NetBox-Funkverbindungen (`WirelessLink`).
Leere Auswahlen erzeugen keinen Zuordnungsdatensatz. Beim Löschen einer
Verbindung wird ihre VLAN-Zuordnung automatisch mit entfernt. Das Feature kann
installationsweit mit `connection_vlans_enabled = False` in `PLUGINS_CONFIG`
deaktiviert werden.
Ist das optionale NetBox-Export-Plugin installiert, werden diese
VLAN-Zuordnungen als normale Plugin-Datensätze zusammen mit den Verbindungen
und VLANs exportiert und wieder importiert.
### Mehrere Geräte nebeneinander in derselben HE
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.
Für eine Fritzbox und ein zweites Gerät in derselben HE wird bei beiden
Geräten beispielsweise **1/2 Rackbreite** gewählt. Die Fritzbox erhält
**Position 1 (links)**, das andere Gerät **Position 2**. Rack, HE und Rackseite
dürfen anschließend identisch sein. Die Kollisionsprüfung berücksichtigt
sowohl die Gerätehöhe als auch die Breite und verhindert horizontale oder
vertikale Überschneidungen. Mehrere HE hohe Geräte werden ebenfalls
unterstützt.
Nach einer Änderung der Rackbreite wird die **Breitenposition** unmittelbar
aktualisiert; ein Zwischenspeichern oder Neuladen ist nicht erforderlich.
Ohne Breitenangabe belegt ein Gerät wie bisher die volle Rackbreite. Ausnahme
ist eine bereits vorhandene gemeinsame Belegung derselben HE und Rackseite:
Fehlen dort Plugin-Platzierungen, teilt die Darstellung den verfügbaren Platz
gleichmäßig auf die zwei bis vier vorhandenen Geräte auf. Diese Ableitung
ändert keine Datenbankwerte und dient nur dazu, bereits gemeinsam eingepflegte
Geräte wieder sichtbar zu machen. Es findet keine Migration bestehender
Platzierungen statt. Explizit gewählte Teilbreite und Position werden auf der
Geräteseite angezeigt und in der Rackgrafik nebeneinander dargestellt.
NetBox besitzt standardmäßig eine Datenbank-Eindeutigkeit für Rack, HE und
Rackseite. Die Plugin-Migration `0007` entfernt ausschließlich diese
Core-Eindeutigkeit, damit mehrere Geräte dieselbe HE verwenden können. Das
Plugin übernimmt dafür die breitenabhängige Prüfung beim Speichern. Vor einem
späteren Entfernen des Plugins müssen geteilte Höheneinheiten wieder aufgelöst
werden; die Core-Eindeutigkeit wird bei einer Deinstallation nicht automatisch
wiederhergestellt.
Die Zusatzfelder werden im NetBox-Webformular gepflegt. REST- oder normale
NetBox-CSV-Vorgänge ohne diese Felder behandeln neue Geräte als volle
Rackbreite. Der nachfolgend beschriebene portable ZIP-Export überträgt sie
dagegen ausdrücklich.
### Rackbreiten in NetBox-Export
Ist das optionale Plugin
[NetBox-Export](https://git.mrblake.cc/MrBlake/netbox-export) in Version
`0.3.11` installiert, erweitert NetBox Utilities dessen ZIP-Export und Import
automatisch. Für jedes Gerät werden auch die Rackbreite und die von links
gezählte Breitenposition im Archiv gespeichert. Beim Import berücksichtigt die
Konfliktprüfung sowohl die Gerätehöhe als auch die horizontale Rackfläche.
Dadurch können beispielsweise zwei Geräte mit halber Breite wieder in dieselbe
HE importiert werden, ohne dass das zuerst importierte Gerät aus dem Rack
entfernt wird.
Auch volle Rackbreite wird ausdrücklich gespeichert. Wird ein früher
teilbreites Zielgerät durch einen neueren Export auf volle Breite zurückgesetzt,
entfernt der Import deshalb seine veraltete Teilbreitenzuordnung. Ältere
Archive ohne diese Zusatzmetadaten bleiben importierbar; vorhandene
`DeviceRackPlacement`-Datensätze darin werden weiterhin berücksichtigt.
Ab Version `0.9.10` sperrt der Import Geräte und ihre optionalen
Rackbreitenzuordnungen in getrennten Datenbankabfragen. Damit funktioniert der
Test- und Echtimport auch unter PostgreSQL, ohne einen unzulässigen
`FOR UPDATE`-Outer-Join zu erzeugen. Bereits mit Version `0.9.9` erstellte
Archive müssen dafür nicht neu exportiert werden.
Ab Version `0.9.11` werden alle im Archiv enthaltenen Rackbreiten vor der
eigentlichen Geräteplatzierung innerhalb derselben Transaktion vorgemerkt.
Dadurch erkennt die Konfliktprüfung mehrere teilbreite Geräte derselben HE
bereits während des Imports korrekt und löst kein zuvor platziertes Gerät aus
dem Rack. Nach der Platzierung werden die Breiten mit dem endgültigen
Importergebnis abgeglichen.
### 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
@@ -142,25 +385,15 @@ weiter. Auch Kurzformen wie `F01` und `R1` werden als dieselbe Kennung erkannt.
Fehlt die passende Gegenseite, bleibt der Port unverknüpft; die nachfolgenden
Nummern rutschen nicht auf.
Die Zuordnung wird nach Änderungen am Gerät, nach dem Einbau eines Moduls und
nach dem Anlegen oder Umbenennen einzelner Front- beziehungsweise Rearports
erneut hergestellt. Dabei ersetzt die Patchpanel-Automatik abweichende
vorhandene Port-Mappings auf diesen Geräten durch die feste 1:1-Zuordnung.
Die Zuordnung wird ausschließlich beim erstmaligen Anlegen eines Patchpanels,
beim Einbau eines neuen Moduls sowie beim Anlegen eines neuen Front- oder
Rearports hergestellt. Das Bearbeiten eines vorhandenen Patchpanels, Moduls
oder Ports löst die Automatik nicht aus.
Beim Update auf Version `0.7.2` korrigiert eine Plugin-Migration die Zuordnung
automatisch für alle bereits vorhandenen Geräte mit dieser Rolle und berechnet
die betroffenen Kabelpfade beziehungsweise Verbindungsenden neu. Vor dem
Produktivupdate sollte daher geprüft werden, ob Geräte mit bewusst
abweichenden oder mehrpoligen Mappings nicht die Rolle `Patchpanel` tragen
sollen.
Der Reparaturlauf kann bei Bedarf jederzeit erneut und ohne erneute Migration
gestartet werden:
```bash
cd /opt/netbox/netbox
/opt/netbox/venv/bin/python manage.py repair_patchpanel_mappings
```
Bei Installation oder Update findet kein automatischer Bestandslauf statt.
Vorhandene Patchpanel-Geräte und deren Kabelpfade bleiben unverändert. Die
Automatik greift erst, wenn ein betreffendes Gerät, Modul oder ein Front-/Rearport
neu angelegt wird.
### Mehrere Geräte mit NetBox Reorder Rack verschieben
@@ -168,6 +401,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:
@@ -187,8 +427,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
@@ -201,7 +441,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
+14 -2
View File
@@ -1,12 +1,12 @@
from netbox.plugins import PluginConfig, get_plugin_config
__version__ = "0.7.2"
__version__ = "0.10.3"
class NetBoxUtilitiesConfig(PluginConfig):
name = "netbox_utilities"
verbose_name = "NetBox Utilities"
description = "Navigation, tenant utilities, patchpanel mapping, bulk uploads, and atomic rack reordering"
description = "Navigation, tenant utilities, connection VLANs, partial-width racks, and bulk operations"
version = __version__
author = "LKE"
base_url = "utilities"
@@ -14,7 +14,9 @@ class NetBoxUtilitiesConfig(PluginConfig):
max_version = "4.6.99"
default_settings = {
"navigation_customization_enabled": True,
"connection_vlans_enabled": True,
"reorder_rack_bulk_save_enabled": True,
"topology_views_rack_width_enabled": True,
"tenant_filter_enabled": True,
"tenant_required": True,
}
@@ -25,12 +27,22 @@ class NetBoxUtilitiesConfig(PluginConfig):
def ready(self):
super().ready()
from .patchpanel import install_patchpanel_automation
from .rack_width import install_rack_width_support
from .tenant_scope import install_search_filter
from .tenant_validation import install_tenant_validation
install_search_filter()
install_tenant_validation()
install_patchpanel_automation()
install_rack_width_support()
if get_plugin_config("netbox_utilities", "connection_vlans_enabled"):
from .connection_vlans import install_connection_vlan_support
install_connection_vlan_support()
if self.apps.is_installed("netbox_export"):
from .netbox_export import install_netbox_export_rack_width_support
install_netbox_export_rack_width_support()
if get_plugin_config("netbox_utilities", "reorder_rack_bulk_save_enabled"):
from .reorder_rack import install_reorder_rack_bulk_save
+150
View File
@@ -0,0 +1,150 @@
from copy import deepcopy
from functools import wraps
from ipam.models import VLAN, VLANGroup
from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField
from utilities.forms.rendering import FieldSet
from .models import CableVLANAssignment, WirelessLinkVLANAssignment
FORM_FIELD = "utilities_vlans"
GROUP_FILTER_FIELD = "utilities_vlan_group"
PATCH_MARKER = "_netbox_utilities_connection_vlans_installed"
def include_connection_vlans_in_fieldsets(fieldsets):
"""Add the connection VLAN field to an isolated copy of a form layout."""
if not fieldsets:
return (FieldSet(GROUP_FILTER_FIELD, FORM_FIELD, name="VLANs der Verbindung"),)
copied_fieldsets = deepcopy(fieldsets)
def insert_into_link_group(group):
items = list(getattr(group, "items", ()))
if "description" in items or "tags" in items:
items = [item for item in items if item not in (GROUP_FILTER_FIELD, FORM_FIELD)]
index = items.index("tags") if "tags" in items else len(items)
items[index:index] = (GROUP_FILTER_FIELD, FORM_FIELD)
group.items = tuple(items)
return True
for item in items:
if any(insert_into_link_group(nested) for nested in getattr(item, "groups", ())):
return True
return False
if any(insert_into_link_group(fieldset) for fieldset in copied_fieldsets):
return tuple(copied_fieldsets)
return (*copied_fieldsets, FieldSet(GROUP_FILTER_FIELD, FORM_FIELD, name="VLANs der Verbindung"))
def _build_connection_vlan_form(base_form, assignment_model, target_field):
if getattr(base_form, PATCH_MARKER, False):
return base_form
class ConnectionVLANForm(base_form):
utilities_vlan_group = DynamicModelChoiceField(
queryset=VLANGroup.objects.all(),
required=False,
selector=True,
label="VLAN-Gruppe (Filter)",
help_text="Optional: Schränkt die nachfolgende VLAN-Auswahl auf diese Gruppe ein.",
)
utilities_vlans = DynamicModelMultipleChoiceField(
queryset=VLAN.objects.all(),
required=False,
selector=True,
query_params={"group_id": f"${GROUP_FILTER_FIELD}"},
label="VLANs der Verbindung",
help_text=(
"Dokumentiert ein oder mehrere VLANs auf dieser Verbindung. "
"Die Tagged-/Untagged-VLAN-Einstellungen der Interfaces bleiben unverändert."
),
)
@property
def fieldsets(self):
return include_connection_vlans_in_fieldsets(super().fieldsets)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if not self.is_bound and getattr(self.instance, "pk", None):
assignment = (
assignment_model.objects.filter(**{target_field: self.instance}).prefetch_related("vlans").first()
)
if assignment is not None:
selected_vlans = list(assignment.vlans.all())
self.initial[FORM_FIELD] = selected_vlans
group_ids = {vlan.group_id for vlan in selected_vlans if getattr(vlan, "group_id", None)}
if len(group_ids) == 1:
self.initial[GROUP_FILTER_FIELD] = group_ids.pop()
def save(self, commit=True):
connection = super().save(commit=commit)
if commit:
self._save_connection_vlans(connection)
else:
native_save_m2m = self.save_m2m
def save_m2m():
native_save_m2m()
self._save_connection_vlans(connection)
self.save_m2m = save_m2m
return connection
def _save_connection_vlans(self, connection):
selected_vlans = list(self.cleaned_data.get(FORM_FIELD) or ())
lookup = {target_field: connection}
if not selected_vlans:
assignment_model.objects.filter(**lookup).delete()
return
assignment, _created = assignment_model.objects.get_or_create(**lookup)
assignment.vlans.set(selected_vlans)
ConnectionVLANForm.__name__ = f"ConnectionVLAN{base_form.__name__}"
ConnectionVLANForm.__qualname__ = ConnectionVLANForm.__name__
ConnectionVLANForm.__module__ = __name__
setattr(ConnectionVLANForm, PATCH_MARKER, True)
return ConnectionVLANForm
def _install_cable_vlan_form():
import dcim.forms
from dcim.forms import connections
from dcim.views import CableEditView
if getattr(CableEditView, PATCH_MARKER, False):
return
native_get_cable_form = dcim.forms.get_cable_form
@wraps(native_get_cable_form)
def get_connection_vlan_cable_form(*args, **kwargs):
base_form = native_get_cable_form(*args, **kwargs)
return _build_connection_vlan_form(base_form, CableVLANAssignment, "cable")
setattr(get_connection_vlan_cable_form, PATCH_MARKER, True)
dcim.forms.get_cable_form = get_connection_vlan_cable_form
connections.get_cable_form = get_connection_vlan_cable_form
CableEditView.template_name = "netbox_utilities/cable_edit.html"
CableEditView.htmx_template_name = "netbox_utilities/cable_edit_form.html"
setattr(CableEditView, PATCH_MARKER, True)
def _install_wireless_link_vlan_form():
from wireless.views import WirelessLinkEditView
if getattr(WirelessLinkEditView, PATCH_MARKER, False):
return
WirelessLinkEditView.form = _build_connection_vlan_form(
WirelessLinkEditView.form,
WirelessLinkVLANAssignment,
"wireless_link",
)
setattr(WirelessLinkEditView, PATCH_MARKER, True)
def install_connection_vlan_support():
_install_cable_vlan_form()
_install_wireless_link_vlan_form()
@@ -1,15 +0,0 @@
from django.core.management.base import BaseCommand
from netbox_utilities.patchpanel import repair_existing_patchpanels
class Command(BaseCommand):
help = "Repair front/rear mappings and cable paths for all devices with the Patchpanel role"
def handle(self, *args, **options):
device_count, mapping_count = repair_existing_patchpanels()
self.stdout.write(
self.style.SUCCESS(
f"Repaired {mapping_count} front/rear mappings on {device_count} Patchpanel devices."
)
)
+4 -1
View File
@@ -22,7 +22,10 @@ class GlobalTenantFilterMiddleware:
try:
if scope is not None and request.method in {"GET", "HEAD"}:
self._inject_filter_parameter(request, scope)
return self.get_response(request)
response = self.get_response(request)
from .topology_views import apply_topology_rack_widths
return apply_topology_rack_widths(request, response)
finally:
active_tenant_scope.reset(token)
@@ -1,55 +1,12 @@
import re
from django.db import migrations
def natural_port_key(port):
parts = tuple(
int(part) if part.isdigit() else part.casefold()
for part in re.split(r"(\d+)", str(port.name))
)
return parts, port.pk
def synchronize_existing_patchpanels(apps, schema_editor):
Device = apps.get_model("dcim", "Device")
FrontPort = apps.get_model("dcim", "FrontPort")
PortMapping = apps.get_model("dcim", "PortMapping")
RearPort = apps.get_model("dcim", "RearPort")
database = schema_editor.connection.alias
device_ids = Device.objects.using(database).filter(role__name__iexact="Patchpanel").values_list("pk", flat=True)
for device_id in device_ids.iterator():
front_ports = sorted(
FrontPort.objects.using(database).filter(device_id=device_id).only("pk", "name"),
key=natural_port_key,
)
rear_ports = sorted(
RearPort.objects.using(database).filter(device_id=device_id).only("pk", "name"),
key=natural_port_key,
)
PortMapping.objects.using(database).filter(device_id=device_id).delete()
PortMapping.objects.using(database).bulk_create(
[
PortMapping(
device_id=device_id,
front_port_id=front_port.pk,
rear_port_id=rear_port.pk,
front_port_position=1,
rear_port_position=1,
)
for front_port, rear_port in zip(front_ports, rear_ports)
],
batch_size=1000,
)
class Migration(migrations.Migration):
dependencies = [
("dcim", "0237_module_remove_local_context_data"),
("netbox_utilities", "0003_navigationpreference_sidebar_layout"),
]
operations = [
migrations.RunPython(synchronize_existing_patchpanels, migrations.RunPython.noop),
]
# Kept in the migration graph for installations which already recorded it.
# Existing devices must not be modified automatically.
operations = []
@@ -1,81 +1,11 @@
import re
from collections import defaultdict
from django.db import migrations
def natural_port_key(port):
parts = tuple(
int(part) if part.isdigit() else part.casefold()
for part in re.split(r"(\d+)", str(port.name))
)
return parts, port.pk
def port_identifier(port):
name = str(port.name).casefold()
name = re.sub(r"\b(?:front|rear)(?:[\s_-]*port)?(?=\b|\d)", " ", name)
name = re.sub(r"^(?:fp|rp|f|r)(?=\s*[-_.:/]?\s*\d)", "", name)
tokens = re.findall(r"\d+|[^\W\d_]+", name)
identifier = tuple(int(token) if token.isdigit() else token for token in tokens)
return identifier or None
def pair_ports(front_ports, rear_ports):
front_by_identifier = defaultdict(list)
rear_by_identifier = defaultdict(list)
for port in front_ports:
if identifier := port_identifier(port):
front_by_identifier[identifier].append(port)
for port in rear_ports:
if identifier := port_identifier(port):
rear_by_identifier[identifier].append(port)
pairs = []
for identifier, matching_front_ports in front_by_identifier.items():
pairs.extend(
zip(
sorted(matching_front_ports, key=natural_port_key),
sorted(rear_by_identifier.get(identifier, []), key=natural_port_key),
)
)
return pairs
def fix_existing_patchpanel_mappings(apps, schema_editor):
Device = apps.get_model("dcim", "Device")
FrontPort = apps.get_model("dcim", "FrontPort")
PortMapping = apps.get_model("dcim", "PortMapping")
RearPort = apps.get_model("dcim", "RearPort")
database = schema_editor.connection.alias
device_ids = Device.objects.using(database).filter(role__name__iexact="Patchpanel").values_list("pk", flat=True)
for device_id in device_ids.iterator():
front_ports = FrontPort.objects.using(database).filter(device_id=device_id).only("pk", "name")
rear_ports = RearPort.objects.using(database).filter(device_id=device_id).only("pk", "name")
pairs = pair_ports(front_ports, rear_ports)
PortMapping.objects.using(database).filter(device_id=device_id).delete()
PortMapping.objects.using(database).bulk_create(
[
PortMapping(
device_id=device_id,
front_port_id=front_port.pk,
rear_port_id=rear_port.pk,
front_port_position=1,
rear_port_position=1,
)
for front_port, rear_port in pairs
],
batch_size=1000,
)
class Migration(migrations.Migration):
dependencies = [
("netbox_utilities", "0004_patchpanel_port_mappings"),
]
operations = [
migrations.RunPython(fix_existing_patchpanel_mappings, migrations.RunPython.noop),
]
# Kept in the migration graph for installations which already recorded it.
# Existing devices must not be modified automatically.
operations = []
@@ -1,19 +1,11 @@
from django.db import migrations
def repair_mappings_and_paths(apps, schema_editor):
# Runtime models are intentional here: NetBox's CablePath.retrace() is
# required to refresh the denormalized path data after migration 0005.
from netbox_utilities.patchpanel import repair_existing_patchpanels
repair_existing_patchpanels()
class Migration(migrations.Migration):
dependencies = [
("netbox_utilities", "0005_fix_patchpanel_port_number_mappings"),
]
operations = [
migrations.RunPython(repair_mappings_and_paths, migrations.RunPython.noop),
]
# Kept in the migration graph for installations which already recorded it.
# Existing devices and cable paths must not be modified automatically.
operations = []
@@ -0,0 +1,65 @@
import django.core.validators
import django.db.models.deletion
from django.db import migrations, models
def drop_core_rack_position_constraint(apps, schema_editor):
if schema_editor.connection.vendor == "postgresql":
schema_editor.execute(
'ALTER TABLE "dcim_device" DROP CONSTRAINT IF EXISTS "dcim_device_unique_rack_position_face"'
)
class Migration(migrations.Migration):
dependencies = [
("dcim", "0237_module_remove_local_context_data"),
("netbox_utilities", "0006_retrace_existing_patchpanel_paths"),
]
operations = [
migrations.CreateModel(
name="DeviceRackPlacement",
fields=[
(
"id",
models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID"),
),
(
"width",
models.PositiveSmallIntegerField(
choices=[(2, "1/2 Rackbreite"), (3, "1/3 Rackbreite"), (4, "1/4 Rackbreite")]
),
),
(
"horizontal_position",
models.PositiveSmallIntegerField(
help_text="Position von links innerhalb der Rackbreite.",
validators=[
django.core.validators.MinValueValidator(1),
django.core.validators.MaxValueValidator(4),
],
),
),
("updated", models.DateTimeField(auto_now=True)),
(
"device",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="netbox_utilities_rack_placement",
to="dcim.device",
),
),
],
options={
"verbose_name": "Gerätebreite im Rack",
"verbose_name_plural": "Gerätebreiten im Rack",
"constraints": [
models.CheckConstraint(
condition=models.Q(width__gte=models.F("horizontal_position")),
name="netbox_utilities_placement_position_within_width",
)
],
},
),
migrations.RunPython(drop_core_rack_position_constraint, migrations.RunPython.noop),
]
@@ -0,0 +1,73 @@
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("ipam", "0001_squashed"),
("netbox_utilities", "0007_devicerackplacement"),
("wireless", "0001_squashed_0008"),
]
operations = [
migrations.CreateModel(
name="CableVLANAssignment",
fields=[
(
"id",
models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID"),
),
("updated", models.DateTimeField(auto_now=True)),
(
"cable",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="netbox_utilities_vlan_assignment",
to="dcim.cable",
),
),
(
"vlans",
models.ManyToManyField(
blank=True,
related_name="netbox_utilities_cable_assignments",
to="ipam.vlan",
),
),
],
options={
"verbose_name": "VLAN-Zuordnung einer Kabelverbindung",
"verbose_name_plural": "VLAN-Zuordnungen von Kabelverbindungen",
},
),
migrations.CreateModel(
name="WirelessLinkVLANAssignment",
fields=[
(
"id",
models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID"),
),
("updated", models.DateTimeField(auto_now=True)),
(
"vlans",
models.ManyToManyField(
blank=True,
related_name="netbox_utilities_wireless_link_assignments",
to="ipam.vlan",
),
),
(
"wireless_link",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="netbox_utilities_vlan_assignment",
to="wireless.wirelesslink",
),
),
],
options={
"verbose_name": "VLAN-Zuordnung einer Funkverbindung",
"verbose_name_plural": "VLAN-Zuordnungen von Funkverbindungen",
},
),
]
+75
View File
@@ -46,3 +46,78 @@ class UtilitiesSettings(models.Model):
def __str__(self):
return "NetBox Utilities settings"
class DeviceRackPlacement(models.Model):
WIDTH_CHOICES = (
(2, "1/2 Rackbreite"),
(3, "1/3 Rackbreite"),
(4, "1/4 Rackbreite"),
)
device = models.OneToOneField(
"dcim.Device",
on_delete=models.CASCADE,
related_name="netbox_utilities_rack_placement",
)
width = models.PositiveSmallIntegerField(choices=WIDTH_CHOICES)
horizontal_position = models.PositiveSmallIntegerField(
validators=[MinValueValidator(1), MaxValueValidator(4)],
help_text="Position von links innerhalb der Rackbreite.",
)
updated = models.DateTimeField(auto_now=True)
class Meta:
constraints = (
models.CheckConstraint(
condition=models.Q(width__gte=models.F("horizontal_position")),
name="netbox_utilities_placement_position_within_width",
),
)
verbose_name = "Gerätebreite im Rack"
verbose_name_plural = "Gerätebreiten im Rack"
def __str__(self):
return f"{self.device}: 1/{self.width}, Position {self.horizontal_position}"
class CableVLANAssignment(models.Model):
cable = models.OneToOneField(
"dcim.Cable",
on_delete=models.CASCADE,
related_name="netbox_utilities_vlan_assignment",
)
vlans = models.ManyToManyField(
"ipam.VLAN",
related_name="netbox_utilities_cable_assignments",
blank=True,
)
updated = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = "VLAN-Zuordnung einer Kabelverbindung"
verbose_name_plural = "VLAN-Zuordnungen von Kabelverbindungen"
def __str__(self):
return f"VLANs für Kabel {self.cable}"
class WirelessLinkVLANAssignment(models.Model):
wireless_link = models.OneToOneField(
"wireless.WirelessLink",
on_delete=models.CASCADE,
related_name="netbox_utilities_vlan_assignment",
)
vlans = models.ManyToManyField(
"ipam.VLAN",
related_name="netbox_utilities_wireless_link_assignments",
blank=True,
)
updated = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = "VLAN-Zuordnung einer Funkverbindung"
verbose_name_plural = "VLAN-Zuordnungen von Funkverbindungen"
def __str__(self):
return f"VLANs für Funkverbindung {self.wireless_link}"
+226
View File
@@ -0,0 +1,226 @@
import logging
from dataclasses import dataclass
from decimal import Decimal
from functools import wraps
from importlib import import_module
from django.apps import apps
from django.core.exceptions import ValidationError
from .models import DeviceRackPlacement
from .rack_width import (
FULL_WIDTH,
get_width_position,
normalize_width_position,
placement_rectangles_overlap,
stage_width_position,
)
logger = logging.getLogger(__name__)
SUPPORTED_NETBOX_EXPORT_VERSIONS = {"0.3.11"}
RACK_WIDTH_RECORD_KEY = "netbox_utilities_rack_placement"
PATCH_MARKER = "_netbox_utilities_rack_width_version"
PATCH_VERSION = 1
@dataclass(frozen=True)
class WidthAwareDeferredDevicePlacement:
rack_spec: dict | None
position: object
face: object
width: int
horizontal_position: int
def _is_device(value):
return getattr(getattr(value, "_meta", None), "label_lower", None) == "dcim.device"
def _serialize_rack_width(original, obj, *args, **kwargs):
record = original(obj, *args, **kwargs)
if _is_device(obj):
width, horizontal_position = get_width_position(obj)
record[RACK_WIDTH_RECORD_KEY] = {
"schema_version": 1,
"width": width,
"horizontal_position": horizontal_position,
}
return record
def _defer_rack_width(original, model, record):
prepared, placement = original(model, record)
if placement is None:
return prepared, placement
metadata = record.get(RACK_WIDTH_RECORD_KEY)
if not isinstance(metadata, dict) or metadata.get("schema_version") != 1:
return prepared, placement
try:
width, horizontal_position = normalize_width_position(
metadata.get("width"),
metadata.get("horizontal_position"),
)
except ValidationError:
logger.warning("NetBox Export archive contains invalid rack width metadata for %s", record.get("id"))
return prepared, placement
return prepared, WidthAwareDeferredDevicePlacement(
rack_spec=placement.rack_spec,
position=placement.position,
face=placement.face,
width=width,
horizontal_position=horizontal_position,
)
def _device_footprint(device):
device_type = getattr(device, "device_type", None)
height = Decimal(str(getattr(device_type, "u_height", 1) or 0))
if height <= 0:
height = Decimal("0.5")
return height, bool(getattr(device_type, "is_full_depth", False))
def _width_aware_device_placement_conflicts(original, device, rack, position, face):
if not _is_device(device):
return original(device, rack, position, face)
if rack is None or position is None:
return []
width, horizontal_position = get_width_position(device)
height, full_depth = _device_footprint(device)
candidates = list(
type(device)
._default_manager.select_for_update()
.select_related("device_type")
.filter(rack=rack, position__isnull=False)
.exclude(pk=device.pk)
)
stored_placements = {
placement.device_id: placement
for placement in DeviceRackPlacement.objects.select_for_update().filter(
device_id__in=[candidate.pk for candidate in candidates]
)
}
conflicts = []
for candidate in candidates:
candidate_height, candidate_full_depth = _device_footprint(candidate)
if not (full_depth or candidate_full_depth or candidate.face == face):
continue
stored_placement = stored_placements.get(candidate.pk)
if stored_placement is None:
candidate_width, candidate_horizontal_position = FULL_WIDTH, 1
else:
candidate_width, candidate_horizontal_position = normalize_width_position(
stored_placement.width,
stored_placement.horizontal_position,
)
if placement_rectangles_overlap(
position,
height,
width,
horizontal_position,
candidate.position,
candidate_height,
candidate_width,
candidate_horizontal_position,
):
conflicts.append(candidate)
return conflicts
def _apply_rack_widths(original, placements, resolved, resolver, compatibility, **kwargs):
placements = list(placements)
width_aware = []
for record_id, placement in placements:
if not isinstance(placement, WidthAwareDeferredDevicePlacement):
continue
device = resolved[record_id]
stage_width_position(device, placement.width, placement.horizontal_position)
width_aware.append((device, placement))
# The native placement pass reloads rack occupants from the database for
# every device. Persist all imported widths first so a device placed early
# in the same pass is not mistaken for a full-width conflict later on.
for device, placement in width_aware:
_save_rack_width(device, placement)
result = original(placements, resolved, resolver, compatibility, **kwargs)
for device, placement in width_aware:
if placement.width == FULL_WIDTH or not device.rack_id or not device.position:
DeviceRackPlacement.objects.filter(device=device).delete()
else:
_save_rack_width(device, placement)
return result
def _save_rack_width(device, placement):
if placement.width == FULL_WIDTH:
DeviceRackPlacement.objects.filter(device=device).delete()
return
DeviceRackPlacement.objects.update_or_create(
device=device,
defaults={
"width": placement.width,
"horizontal_position": placement.horizontal_position,
},
)
def _rack_placement_is_exportable(original, model):
if model is DeviceRackPlacement:
return True
return original(model)
def _patch_function(module, name, wrapper_factory):
current = getattr(module, name)
if getattr(current, PATCH_MARKER, None) == PATCH_VERSION:
return
original = current
@wraps(original)
def patched(*args, **kwargs):
return wrapper_factory(original, *args, **kwargs)
setattr(patched, PATCH_MARKER, PATCH_VERSION)
setattr(module, name, patched)
def install_netbox_export_rack_width_support():
if not apps.is_installed("netbox_export"):
return False
plugin_config = apps.get_app_config("netbox_export")
plugin_version = getattr(plugin_config, "version", None)
if plugin_version not in SUPPORTED_NETBOX_EXPORT_VERSIONS:
logger.warning(
"NetBox Utilities did not patch netbox-export version %s; supported versions: %s",
plugin_version,
", ".join(sorted(SUPPORTED_NETBOX_EXPORT_VERSIONS)),
)
return False
exporter = import_module("netbox_export.services.exporter")
graph = import_module("netbox_export.services.graph")
importer = import_module("netbox_export.services.importer")
required = (
(exporter, "serialize_object"),
(graph, "is_exportable_model"),
(importer, "_defer_device_placement"),
(importer, "_device_placement_conflicts"),
(importer, "_apply_device_placements"),
)
if not all(hasattr(module, name) for module, name in required):
logger.warning("NetBox Utilities could not find the expected netbox-export 0.3.11 integration hooks")
return False
_patch_function(exporter, "serialize_object", _serialize_rack_width)
_patch_function(graph, "is_exportable_model", _rack_placement_is_exportable)
_patch_function(importer, "_defer_device_placement", _defer_rack_width)
_patch_function(importer, "_device_placement_conflicts", _width_aware_device_placement_conflicts)
_patch_function(importer, "_apply_device_placements", _apply_rack_widths)
return True
+7 -19
View File
@@ -75,7 +75,7 @@ def retrace_patchpanel_paths(ports):
return retraced
def synchronize_patchpanel(device_id, *, force_retrace=False):
def synchronize_patchpanel(device_id):
"""Enforce front position 1 -> rear position 1 for a Patchpanel device."""
if not device_id:
return 0
@@ -113,28 +113,12 @@ def synchronize_patchpanel(device_id, *, force_retrace=False):
for front, rear in pairs
]
)
force_retrace = True
if force_retrace:
retrace_patchpanel_paths([*front_ports, *rear_ports])
return len(pairs)
def repair_existing_patchpanels():
"""Repair mappings and cable paths for all existing Patchpanel devices."""
from dcim.models import Device
device_ids = Device.objects.filter(role__name__iexact=PATCHPANEL_ROLE_NAME).values_list("pk", flat=True)
device_count = 0
mapping_count = 0
for device_id in device_ids:
mapping_count += synchronize_patchpanel(device_id, force_retrace=True)
device_count += 1
return device_count, mapping_count
def _component_saved(sender, instance, raw=False, **kwargs):
if not raw and not _component_instantiation_active.get():
def _component_saved(sender, instance, created=False, raw=False, **kwargs):
if created and not raw and not _component_instantiation_active.get():
synchronize_patchpanel(getattr(instance, "device_id", None))
@@ -147,6 +131,10 @@ def _wrap_component_container_save(model, device_id_getter):
@wraps(original_save)
def patchpanel_aware_save(instance, *args, **kwargs):
created = instance._state.adding
if not created:
return original_save(instance, *args, **kwargs)
token = _component_instantiation_active.set(True)
try:
result = original_save(instance, *args, **kwargs)
+592
View File
@@ -0,0 +1,592 @@
import decimal
import logging
from collections import defaultdict
from contextvars import ContextVar
from copy import deepcopy
from fractions import Fraction
from functools import wraps
from django import forms
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.db.models import Count, Q
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"),
(2, "1/2 Rackbreite"),
(3, "1/3 Rackbreite"),
(4, "1/4 Rackbreite"),
)
POSITION_CHOICES = (
(1, "Position 1 (links)"),
(2, "Position 2"),
(3, "Position 3"),
(4, "Position 4 (rechts)"),
)
CORE_RACK_POSITION_CONSTRAINT = "dcim_device_unique_rack_position_face"
_validated_device = ContextVar("netbox_utilities_width_validated_device", default=None)
RACK_WIDTH_FORM_FIELDS = ("utilities_rack_width", "utilities_horizontal_position")
def normalize_width_position(width, horizontal_position):
try:
width = int(width or FULL_WIDTH)
horizontal_position = int(horizontal_position or 1)
except (TypeError, ValueError):
raise ValidationError("Rackbreite und Breitenposition sind ungültig.") from None
if width not in {1, 2, 3, 4}:
raise ValidationError("Die Rackbreite muss voll, 1/2, 1/3 oder 1/4 sein.")
if width == FULL_WIDTH:
return FULL_WIDTH, 1
if not 1 <= horizontal_position <= width:
raise ValidationError(f"Für 1/{width} Rackbreite sind nur die Positionen 1 bis {width} möglich.")
return width, horizontal_position
def horizontal_interval(width, horizontal_position):
width, horizontal_position = normalize_width_position(width, horizontal_position)
return Fraction(horizontal_position - 1, width), Fraction(horizontal_position, width)
def intervals_overlap(first, second):
return max(first[0], second[0]) < min(first[1], second[1])
def intervals_cover_full_width(intervals):
if not intervals:
return False
merged_end = Fraction(0, 1)
for start, end in sorted(intervals):
if start > merged_end:
return False
merged_end = max(merged_end, end)
return merged_end >= 1
def placement_rectangles_overlap(
first_position,
first_height,
first_width,
first_horizontal_position,
second_position,
second_height,
second_width,
second_horizontal_position,
):
first_vertical = (
decimal.Decimal(str(first_position)),
decimal.Decimal(str(first_position)) + decimal.Decimal(str(first_height)),
)
second_vertical = (
decimal.Decimal(str(second_position)),
decimal.Decimal(str(second_position)) + decimal.Decimal(str(second_height)),
)
return intervals_overlap(first_vertical, second_vertical) and intervals_overlap(
horizontal_interval(first_width, first_horizontal_position),
horizontal_interval(second_width, second_horizontal_position),
)
def _stored_width_position(device):
if not getattr(device, "pk", None):
return FULL_WIDTH, 1
try:
placement = device.netbox_utilities_rack_placement
except (AttributeError, ObjectDoesNotExist):
return FULL_WIDTH, 1
return placement.width, placement.horizontal_position
def get_width_position(device, placements=None):
staged_width = getattr(device, "_netbox_utilities_rack_width", None)
staged_position = getattr(device, "_netbox_utilities_horizontal_position", None)
if staged_width is not None:
return normalize_width_position(staged_width, staged_position)
if placements is not None and device.pk in placements:
placement = placements[device.pk]
return placement.width, placement.horizontal_position
return _stored_width_position(device)
def effective_width_positions(devices, placements=None):
"""Return stored rack widths and infer missing rows for devices sharing one slot."""
devices = list(devices)
groups = defaultdict(list)
result = {}
for device in devices:
key = (
device.rack_id,
device.face,
decimal.Decimal(str(device.position)),
decimal.Decimal(str(device.device_type.u_height)),
)
groups[key].append(device)
for group in groups.values():
stored = {}
missing = []
for device in group:
if placements is not None:
placement = placements.get(device.pk)
else:
try:
placement = device.netbox_utilities_rack_placement
except ObjectDoesNotExist:
placement = None
if placement is None:
missing.append(device)
else:
stored[device.pk] = normalize_width_position(placement.width, placement.horizontal_position)
candidate_width = None
stored_widths = {width for width, _position in stored.values()}
if len(stored_widths) == 1:
stored_width = next(iter(stored_widths))
if len(group) <= stored_width:
candidate_width = stored_width
elif not stored and 2 <= len(group) <= 4:
candidate_width = len(group)
if candidate_width is not None:
used_positions = {position for width, position in stored.values() if width == candidate_width}
free_positions = iter(
position for position in range(1, candidate_width + 1) if position not in used_positions
)
for device in sorted(missing, key=lambda item: item.pk):
position = next(free_positions, None)
if position is None:
break
result[device.pk] = (candidate_width, position, "inferred")
for device_id, (width, position) in stored.items():
result[device_id] = (width, position, "stored")
for device in missing:
result.setdefault(device.pk, (FULL_WIDTH, 1, "default"))
return result
def stage_width_position(device, width, horizontal_position):
width, horizontal_position = normalize_width_position(width, horizontal_position)
device._netbox_utilities_rack_width = width
device._netbox_utilities_horizontal_position = horizontal_position
return width, horizontal_position
def include_rack_width_in_fieldsets(fieldsets):
"""Return an isolated fieldset layout which renders both rack width fields."""
if not fieldsets:
return fieldsets
copied_fieldsets = deepcopy(fieldsets)
def insert_after_position(group):
items = list(getattr(group, "items", ()))
if "position" in items:
index = items.index("position") + 1
for field_name in reversed(RACK_WIDTH_FORM_FIELDS):
if field_name not in items:
items.insert(index, field_name)
group.items = tuple(items)
return True
for item in items:
nested_groups = getattr(item, "groups", ())
if any(insert_after_position(nested_group) for nested_group in nested_groups):
return True
return False
if any(insert_after_position(fieldset) for fieldset in copied_fieldsets):
return tuple(copied_fieldsets)
from utilities.forms.rendering import FieldSet
return (
*copied_fieldsets,
FieldSet(*RACK_WIDTH_FORM_FIELDS, name="Rackbreite im Rack"),
)
def _vertical_interval(device, position=None):
start = decimal.Decimal(position if position is not None else device.position)
height = decimal.Decimal(str(device.device_type.u_height))
return start, start + height
def _faces_overlap(first, second):
return bool(first.device_type.is_full_depth or second.device_type.is_full_depth or first.face == second.face)
def _placement_map(device_ids):
return {
placement.device_id: placement for placement in DeviceRackPlacement.objects.filter(device_id__in=device_ids)
}
def find_placement_conflict(device, *, position=None, exclude=None, ignore_excluded_devices=False):
if not getattr(device, "rack_id", None) or not (position if position is not None else device.position):
return None
try:
candidate_vertical = _vertical_interval(device, position)
except (AttributeError, ObjectDoesNotExist, TypeError, ValueError):
return None
candidate_horizontal = horizontal_interval(*get_width_position(device))
from dcim.models import Device
devices = Device.objects.filter(rack_id=device.rack_id, position__gte=1).select_related("device_type")
excluded_ids = set(exclude or ())
if device.pk:
excluded_ids.add(device.pk)
if excluded_ids:
devices = devices.exclude(pk__in=excluded_ids)
if ignore_excluded_devices:
devices = devices.exclude(device_type__exclude_from_utilization=True)
devices = list(devices)
placements = _placement_map([other.pk for other in devices])
for other in devices:
if not _faces_overlap(device, other):
continue
other_horizontal = horizontal_interval(*get_width_position(other, placements))
if intervals_overlap(candidate_vertical, _vertical_interval(other)) and intervals_overlap(
candidate_horizontal,
other_horizontal,
):
return other
return None
def validate_device_placement(device):
if not getattr(device, "rack_id", None) or not getattr(device, "position", None):
return
width, horizontal_position = get_width_position(device)
normalize_width_position(width, horizontal_position)
if conflict := find_placement_conflict(device):
raise ValidationError(
{
"position": (
f"U{device.position} überschneidet sich in der Rackbreite mit dem Gerät {conflict}. "
"Wählen Sie eine freie Breitenposition."
)
}
)
def available_units_for_device(rack, device, *, exclude=None, ignore_excluded_devices=False):
try:
height = decimal.Decimal(str(device.device_type.u_height))
except (AttributeError, ObjectDoesNotExist):
return []
if not device.position:
return []
units = list(rack.units)
required = set(_decimal_range(device.position, decimal.Decimal(device.position) + height))
if not required.issubset(units):
return []
conflict = find_placement_conflict(
device,
exclude=exclude,
ignore_excluded_devices=ignore_excluded_devices,
)
return [device.position] if conflict is None else []
def _decimal_range(start, stop):
current = decimal.Decimal(start)
stop = decimal.Decimal(stop)
while current < stop:
yield current
current += decimal.Decimal("0.5")
def _install_rack_methods():
from dcim.models import Rack
# Version 0.9.0 replaced get_rack_units() globally. A long-running
# process which reloads the plugin must restore NetBox's native method
# before installing the current, non-destructive implementation.
already_installed = getattr(Rack, "_netbox_utilities_rack_width_installed", False)
installed_version = getattr(Rack, "_netbox_utilities_rack_width_version", None)
if already_installed and installed_version is None:
for method_name in ("get_rack_units", "get_available_units"):
method = getattr(Rack, method_name)
if wrapped := getattr(method, "__wrapped__", None):
setattr(Rack, method_name, wrapped)
already_installed = False
if already_installed:
Rack._netbox_utilities_rack_width_version = 2
return
original_available_units = Rack.get_available_units
@wraps(original_available_units)
def width_aware_available_units(
rack,
u_height=1.0,
rack_face=None,
exclude=None,
ignore_excluded_devices=False,
):
device = _validated_device.get()
if device is not None and getattr(device, "rack_id", None) == rack.pk:
return available_units_for_device(
rack,
device,
exclude=exclude,
ignore_excluded_devices=ignore_excluded_devices,
)
return original_available_units(rack, u_height, rack_face, exclude, ignore_excluded_devices)
Rack.get_available_units = width_aware_available_units
Rack._netbox_utilities_rack_width_installed = True
Rack._netbox_utilities_rack_width_version = 2
def _install_device_validation():
from dcim.models import Device
if getattr(Device, "_netbox_utilities_rack_width_installed", False):
return
original_clean = Device.clean
@wraps(original_clean)
def width_aware_clean(device, *args, **kwargs):
token = _validated_device.set(device)
try:
result = original_clean(device, *args, **kwargs)
finally:
_validated_device.reset(token)
validate_device_placement(device)
return result
Device.clean = width_aware_clean
Device._meta.constraints = tuple(
constraint
for constraint in Device._meta.constraints
if not (
constraint.name == CORE_RACK_POSITION_CONSTRAINT
or tuple(getattr(constraint, "fields", ())) == ("rack", "position", "face")
)
)
Device._meta.__dict__.pop("total_unique_constraints", None)
Device._netbox_utilities_rack_width_installed = True
def _install_device_form():
from dcim.views import DeviceEditView
from netbox.registry import registry
if getattr(DeviceEditView, "_netbox_utilities_rack_width_installed", False):
return
base_device_form = DeviceEditView.form
class RackWidthDeviceForm(base_device_form):
utilities_rack_width = forms.ChoiceField(
label="Rackbreite",
choices=WIDTH_CHOICES,
required=False,
initial=FULL_WIDTH,
help_text="Optional: Geräte können sich eine HE nebeneinander teilen.",
)
utilities_horizontal_position = forms.ChoiceField(
label="Breitenposition",
choices=POSITION_CHOICES,
required=False,
initial=1,
help_text="Position von links; bei halber Breite sind Position 1 und 2 möglich.",
)
@property
def fieldsets(self):
return include_rack_width_in_fieldsets(super().fieldsets)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
width, horizontal_position = _stored_width_position(self.instance)
if not self.is_bound:
self.initial["utilities_rack_width"] = width
self.initial["utilities_horizontal_position"] = horizontal_position
reordered = {}
for name, field in self.fields.items():
if name in {"utilities_rack_width", "utilities_horizontal_position"}:
continue
reordered[name] = field
if name == "position":
reordered["utilities_rack_width"] = self.fields["utilities_rack_width"]
reordered["utilities_horizontal_position"] = self.fields["utilities_horizontal_position"]
self.fields = reordered
def clean(self):
super().clean()
cleaned_data = self.cleaned_data
try:
width, horizontal_position = normalize_width_position(
cleaned_data.get("utilities_rack_width"),
cleaned_data.get("utilities_horizontal_position"),
)
except ValidationError as error:
self.add_error("utilities_horizontal_position", error)
return cleaned_data
if not cleaned_data.get("rack") or not cleaned_data.get("position"):
width, horizontal_position = FULL_WIDTH, 1
cleaned_data["utilities_rack_width"] = width
cleaned_data["utilities_horizontal_position"] = horizontal_position
stage_width_position(self.instance, width, horizontal_position)
return cleaned_data
def save(self, commit=True):
device = super().save(commit=commit)
if not commit:
return device
width, horizontal_position = get_width_position(device)
if width == FULL_WIDTH or not device.rack_id or not device.position:
DeviceRackPlacement.objects.filter(device=device).delete()
else:
DeviceRackPlacement.objects.update_or_create(
device=device,
defaults={"width": width, "horizontal_position": horizontal_position},
)
return device
RackWidthDeviceForm.__module__ = __name__
RackWidthDeviceForm.__qualname__ = "RackWidthDeviceForm"
class RackWidthDeviceEditView(DeviceEditView):
form = RackWidthDeviceForm
template_name = "netbox_utilities/device_edit.html"
htmx_template_name = "netbox_utilities/device_edit_form.html"
RackWidthDeviceEditView.__module__ = __name__
RackWidthDeviceEditView.__qualname__ = "RackWidthDeviceEditView"
replaced_views = set()
for view_config in registry["views"]["dcim"]["device"]:
if view_config["name"] in {"add", "edit"}:
view_config["view"] = RackWidthDeviceEditView
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
def _install_rack_svg():
from dcim.svg.racks import RackElevationSVG
if getattr(RackElevationSVG, "_netbox_utilities_rack_width_version", None) == 4:
return
current_draw_face = RackElevationSVG.draw_face
current_globals = getattr(current_draw_face, "__globals__", {})
if current_globals.get("__name__") == __name__ and hasattr(current_draw_face, "__wrapped__"):
original_draw_face = current_draw_face.__wrapped__
else:
original_draw_face = current_draw_face
@wraps(original_draw_face)
def width_aware_draw_face(elevation, face, opposite=False):
from svgwrite.shapes import Rect
# NetBox's get_rack_units() stores only one device per rack unit and
# therefore cannot represent two devices mounted beside each other.
# Query every mounted device directly and render each exactly once.
devices = list(_rack_elevation_devices(elevation.rack, face))
widths = effective_width_positions(devices)
for device in devices:
width_divisor, horizontal_position, _source = widths[device.pk]
height = decimal.Decimal(str(device.device_type.u_height))
coords = elevation._get_device_coords(device.position, height)
width = elevation.unit_width / width_divisor
coords = (coords[0] + width * (horizontal_position - 1), coords[1])
size = (width, int(elevation.unit_height * height))
if device.pk in elevation.permitted_device_ids:
if device.face == face and not opposite:
elevation.draw_device_front(device, coords, size)
else:
elevation.draw_device_rear(device, coords, size)
else:
elevation.drawing.add(Rect(coords, size, class_="blocked"))
RackElevationSVG.draw_face = width_aware_draw_face
RackElevationSVG._netbox_utilities_rack_width_installed = True
RackElevationSVG._netbox_utilities_rack_width_version = 4
def _rack_elevation_devices(rack, face):
"""Return every annotated device which must be drawn on a rack face."""
from dcim.models import Device
return (
Device.objects.filter(
rack=rack,
position__gt=0,
device_type__u_height__gt=0,
)
.filter(Q(face=face) | Q(device_type__is_full_depth=True))
.select_related(
"device_type",
"device_type__manufacturer",
"role",
"virtual_chassis",
"netbox_utilities_rack_placement",
)
.annotate(devicebay_count=Count("devicebays"))
.order_by("position", "pk")
)
def _validate_before_save(sender, instance, raw=False, **kwargs):
if not raw:
using = kwargs.get("using")
if instance.rack_id:
from dcim.models import Rack
from django.db import transaction
connection = transaction.get_connection(using)
if connection.in_atomic_block:
Rack.objects.using(using).select_for_update().filter(pk=instance.rack_id).exists()
validate_device_placement(instance)
def _cleanup_unracked_placement(sender, instance, raw=False, using=None, **kwargs):
if not raw and (not instance.rack_id or not instance.position):
DeviceRackPlacement.objects.using(using).filter(device_id=instance.pk).delete()
def install_rack_width_support():
from dcim.models import Device
_install_rack_methods()
_install_device_validation()
_install_device_form()
_install_rack_svg()
pre_save.connect(
_validate_before_save,
sender=Device,
dispatch_uid="netbox_utilities.validate_device_rack_width",
weak=False,
)
post_save.connect(
_cleanup_unracked_placement,
sender=Device,
dispatch_uid="netbox_utilities.cleanup_device_rack_width",
weak=False,
)
+315 -21
View File
@@ -5,19 +5,27 @@ 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 . import __version__
from .models import DeviceRackPlacement
from .rack_width import FULL_WIDTH, effective_width_positions, normalize_width_position, stage_width_position
logger = logging.getLogger(__name__)
SUPPORTED_REORDER_RACK_VERSIONS = {"1.1.4"}
PATCH_MARKER = "_netbox_utilities_bulk_save"
VIEW_PATCH_MARKER = "_netbox_utilities_width_aware_view"
GRID_COLUMNS = 12
REORDER_RACK_VIEW = "dcim:rack_reorder"
class RackLayoutError(Exception):
@@ -29,9 +37,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 +83,7 @@ def parse_device_placements(layout):
if section == "other":
position = None
grid_x = None
else:
try:
position = Decimal(str(item["y"]))
@@ -61,7 +92,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 +114,84 @@ 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)
}
mounted_devices = [device for device in devices.values() if device.position is not None and device.position > 0]
effective_placements = effective_width_positions(mounted_devices, stored_placements)
target_placements = []
for placement in placements:
current_width, current_horizontal_position, _source = effective_placements.get(
placement.device_id,
(FULL_WIDTH, 1, "default"),
)
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 +203,149 @@ 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 _device_label(device):
return device.label or str(device.device_type)
def _empty_reorder_rack_width_data(request, *, status="ready"):
selected_view = request.GET.get("view", "images-and-labels")
return {
"columns": GRID_COLUMNS,
"unit_width": 220,
"images": selected_view != "labels-only",
"labels": selected_view != "images-only",
"devices": [],
"status": status,
"schema_version": 3,
"complete": False,
}
def get_reorder_rack_width_data(request, *, rack=None, devices=None):
"""Describe every mounted widget for netbox-reorder-rack's GridStack UI."""
# The patched Reorder view passes its already-authorized rack and device
# queryset explicitly. The global helper keeps the permission-scoped
# lookup for callers which do not own the rendered view.
view_owned_data = rack is not None
if not view_owned_data and not reorder_rack_width_enabled(request):
return None
data = _empty_reorder_rack_width_data(request)
rack_id = rack.pk if rack is not None else getattr(request.resolver_match, "kwargs", {}).get("pk")
if not rack_id:
data["status"] = "missing-rack"
return data
from netbox.config import get_config
from utilities.html import foreground_color
try:
if rack is None:
rack = Rack.objects.restrict(request.user, "view").filter(pk=rack_id).first()
if rack is None:
return None
if devices is None:
devices = (
Device.objects.restrict(request.user, "view")
.filter(
rack=rack,
position__gt=0,
device_type__u_height__gt=0,
)
.select_related(
"device_type",
"device_type__manufacturer",
"role",
"virtual_chassis",
"netbox_utilities_rack_placement",
)
.order_by("position", "pk")
)
devices = list(devices)
widths = effective_width_positions(devices)
permission = get_permission_for_model(Device, "change")
for device in devices:
width, horizontal_position, width_source = widths[device.pk]
grid_width, grid_x = grid_dimensions(width, horizontal_position)
role_color = device.role.color or "1685fc"
grid_height = int(Decimal(str(device.device_type.u_height)) * 2)
data["devices"].append(
{
"id": device.pk,
"label": _device_label(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": width,
"horizontal_position": horizontal_position,
"width_source": width_source,
"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),
}
)
data["unit_width"] = get_config().RACK_ELEVATION_DEFAULT_UNIT_WIDTH
data["complete"] = True
return data
except (
AttributeError,
ObjectDoesNotExist,
OperationalError,
ProgrammingError,
RackLayoutError,
TypeError,
ValueError,
):
logger.warning(
"Could not load partial rack widths for netbox-reorder-rack rack %s; using the native rack layout",
rack_id,
exc_info=True,
)
data["status"] = "native-fallback"
return data
def bulk_reorder_update(self, request, pk=None):
serializer = self.serializer_class(data=request.data)
serializer.is_valid(raise_exception=True)
@@ -128,7 +357,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(
@@ -168,6 +402,60 @@ def bulk_reorder_update(self, request, pk=None):
)
def width_aware_reorder_get(self, request, pk):
"""Render every rack device server-side on the native Reorder page."""
from dcim.choices import SubdeviceRoleChoices
from django.conf import settings
from django.shortcuts import render
rack = get_object_or_404(Rack, pk=pk)
selected_view = request.GET.get("view", "images-and-labels")
images = selected_view != "labels-only"
labels = selected_view != "images-only"
non_racked = (
Device.objects.filter(rack=rack, position__isnull=True, parent_bay__isnull=True)
.exclude(device_type__subdevice_role=SubdeviceRoleChoices.ROLE_CHILD)
.select_related("device_type", "role")
)
mounted_devices = (
Device.objects.filter(
rack=rack,
position__gt=0,
device_type__u_height__gt=0,
)
.select_related(
"device_type",
"device_type__manufacturer",
"role",
"virtual_chassis",
"netbox_utilities_rack_placement",
)
.order_by("position", "pk")
)
width_data = get_reorder_rack_width_data(request, rack=rack, devices=mounted_devices)
if not width_data or not width_data.get("complete"):
original_get = getattr(type(self), "_netbox_utilities_original_get", None)
if original_get is not None:
return original_get(self, request, pk)
raise Http404
return render(
request,
"netbox_utilities/reorder_rack.html",
{
"object": rack,
"images": images,
"labels": labels,
"unit_width": width_data["unit_width"],
"reorder_devices": width_data["devices"],
"reorder_rack_width_data": width_data,
"non_racked": non_racked,
"basepath": settings.BASE_PATH,
"asset_version": __version__,
},
)
def install_reorder_rack_bulk_save():
if not apps.is_installed("netbox_reorder_rack"):
return False
@@ -182,13 +470,19 @@ def install_reorder_rack_bulk_save():
)
return False
views = import_module("netbox_reorder_rack.api.views")
save_viewset = views.SaveViewSet
if getattr(save_viewset, PATCH_MARKER, False):
return True
api_views = import_module("netbox_reorder_rack.api.views")
save_viewset = api_views.SaveViewSet
if not getattr(save_viewset, PATCH_MARKER, False):
save_viewset._netbox_utilities_original_update = save_viewset.update
save_viewset.update = bulk_reorder_update
setattr(save_viewset, PATCH_MARKER, True)
page_views = import_module("netbox_reorder_rack.views")
reorder_view = page_views.ReorderView
if not getattr(reorder_view, VIEW_PATCH_MARKER, False):
reorder_view._netbox_utilities_original_get = reorder_view.get
reorder_view.get = width_aware_reorder_get
setattr(reorder_view, VIEW_PATCH_MARKER, True)
save_viewset._netbox_utilities_original_update = save_viewset.update
save_viewset.update = bulk_reorder_update
setattr(save_viewset, PATCH_MARKER, True)
logger.info("Enabled atomic multi-device saving for netbox-reorder-rack %s", plugin_version)
return True
+4
View File
@@ -11,6 +11,10 @@ def navigation_customization_enabled():
return bool(get_plugin_config("netbox_utilities", "navigation_customization_enabled"))
def connection_vlans_enabled():
return bool(get_plugin_config("netbox_utilities", "connection_vlans_enabled"))
def tenant_filter_enabled():
if not get_plugin_config("netbox_utilities", "tenant_filter_enabled"):
return False
@@ -91,10 +91,41 @@
});
}
function configureRackWidth(root = document) {
const widthField = root.querySelector('[name="utilities_rack_width"]');
const positionField = root.querySelector('[name="utilities_horizontal_position"]');
if (!widthField || !positionField || widthField.dataset.netboxUtilitiesWidth === 'true') return;
widthField.dataset.netboxUtilitiesWidth = 'true';
const updatePositions = () => {
const width = Number.parseInt(widthField.value || '1', 10);
Array.from(positionField.options).forEach((option) => {
const position = Number.parseInt(option.value || '1', 10);
option.disabled = position > width;
});
const resetPosition = width === 1 || Number.parseInt(positionField.value || '1', 10) > width;
if (resetPosition) {
positionField.value = '1';
}
positionField.disabled = width === 1;
if (positionField.tomselect) positionField.tomselect.sync();
if (resetPosition) {
positionField.dispatchEvent(new Event('change', {bubbles: true}));
}
};
widthField.addEventListener('change', updatePositions);
updatePositions();
}
function initialize(root = document) {
rewriteImageUploadLinks(root);
configureTenantConfirmation(root);
configureImagePreview(root);
configureRackWidth(root);
}
if (document.readyState === 'loading') {
@@ -29,6 +29,51 @@
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-reorder-rack 1.1.4 bundles GridStack 10, while NetBox 4.6.7
* provides GridStack 11's base stylesheet. GridStack 11 calculates item
* widths through a CSS variable which the older runtime never sets. Without
* these explicit rules every 12-column widget therefore has width: 0px.
* Do not mark these declarations important: while dragging, GridStack writes
* pixel coordinates inline on a fixed-position helper. Those inline values
* must take precedence or the helper is sized relative to the viewport.
*/
.netbox-utilities-reorder-grid[gs-column="12"] > .grid-stack-item {
width: calc(100% / 12);
}
.netbox-utilities-reorder-grid[gs-column="12"] > .grid-stack-item[gs-w="2"] { width: calc(200% / 12); }
.netbox-utilities-reorder-grid[gs-column="12"] > .grid-stack-item[gs-w="3"] { width: 25%; }
.netbox-utilities-reorder-grid[gs-column="12"] > .grid-stack-item[gs-w="4"] { width: calc(400% / 12); }
.netbox-utilities-reorder-grid[gs-column="12"] > .grid-stack-item[gs-w="5"] { width: calc(500% / 12); }
.netbox-utilities-reorder-grid[gs-column="12"] > .grid-stack-item[gs-w="6"] { width: 50%; }
.netbox-utilities-reorder-grid[gs-column="12"] > .grid-stack-item[gs-w="7"] { width: calc(700% / 12); }
.netbox-utilities-reorder-grid[gs-column="12"] > .grid-stack-item[gs-w="8"] { width: calc(800% / 12); }
.netbox-utilities-reorder-grid[gs-column="12"] > .grid-stack-item[gs-w="9"] { width: 75%; }
.netbox-utilities-reorder-grid[gs-column="12"] > .grid-stack-item[gs-w="10"] { width: calc(1000% / 12); }
.netbox-utilities-reorder-grid[gs-column="12"] > .grid-stack-item[gs-w="11"] { width: calc(1100% / 12); }
.netbox-utilities-reorder-grid[gs-column="12"] > .grid-stack-item[gs-w="12"] { width: 100%; }
.netbox-utilities-reorder-grid[gs-column="12"] > .grid-stack-item[gs-x="0"] { left: 0; }
.netbox-utilities-reorder-grid[gs-column="12"] > .grid-stack-item[gs-x="1"] { left: calc(100% / 12); }
.netbox-utilities-reorder-grid[gs-column="12"] > .grid-stack-item[gs-x="2"] { left: calc(200% / 12); }
.netbox-utilities-reorder-grid[gs-column="12"] > .grid-stack-item[gs-x="3"] { left: 25%; }
.netbox-utilities-reorder-grid[gs-column="12"] > .grid-stack-item[gs-x="4"] { left: calc(400% / 12); }
.netbox-utilities-reorder-grid[gs-column="12"] > .grid-stack-item[gs-x="5"] { left: calc(500% / 12); }
.netbox-utilities-reorder-grid[gs-column="12"] > .grid-stack-item[gs-x="6"] { left: 50%; }
.netbox-utilities-reorder-grid[gs-column="12"] > .grid-stack-item[gs-x="7"] { left: calc(700% / 12); }
.netbox-utilities-reorder-grid[gs-column="12"] > .grid-stack-item[gs-x="8"] { left: calc(800% / 12); }
.netbox-utilities-reorder-grid[gs-column="12"] > .grid-stack-item[gs-x="9"] { left: 75%; }
.netbox-utilities-reorder-grid[gs-column="12"] > .grid-stack-item[gs-x="10"] { left: calc(1000% / 12); }
.netbox-utilities-reorder-grid[gs-column="12"] > .grid-stack-item[gs-x="11"] { left: calc(1100% / 12); }
.netbox-utilities-sidebar-resizer {
display: none;
}
@@ -0,0 +1,188 @@
(() => {
'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;
}
if (data.status && data.status !== 'ready') {
console.warn('NetBox Utilities is leaving Reorder Rack in its native layout.', data.status);
return;
}
if (data.schema_version !== 3 || data.complete !== true || !Array.isArray(data.devices)) {
console.warn('NetBox Utilities received an incomplete Reorder Rack payload and kept the native layout.');
return;
}
const validDeviceData = data.devices.every(device => (
Number.isInteger(Number(device.id))
&& ['front', 'rear'].includes(device.face)
&& Number.isInteger(Number(device.grid_x))
&& Number.isInteger(Number(device.grid_y))
&& Number.isInteger(Number(device.grid_width))
&& Number.isInteger(Number(device.grid_height))
));
if (!validDeviceData) {
console.warn('NetBox Utilities received invalid Reorder Rack device data and kept the native layout.');
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 nativeMountedItems = [
...gridElements.front.querySelectorAll('.grid-stack-item'),
...gridElements.rear.querySelectorAll('.grid-stack-item'),
];
if (!data.devices.length && nativeMountedItems.length) {
console.warn('NetBox Utilities received no replacement devices and kept the populated native layout.');
return;
}
const devicesById = new Map(data.devices.map(device => [String(device.id), device]));
const nativeItemsByFace = {};
const setAttribute = (element, name, value) => element.setAttribute(name, String(value));
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.entries(gridElements).forEach(([face, grid]) => {
setAttribute(grid, 'gs-column', columns);
const nativeItemsById = new Map(
Array.from(grid.querySelectorAll('.grid-stack-item')).map(item => [item.getAttribute('gs-id'), item]),
);
grid.querySelectorAll('.grid-stack-item').forEach(item => {
// Keep every native widget whenever possible. Rack.get_rack_units() can
// expose only one device per unit, so missing shared widgets are added
// from the complete payload below.
if (face !== 'other') {
const device = devicesById.get(item.getAttribute('gs-id'));
const belongsOnFace = device && (device.face === face || device.full_depth);
if (!belongsOnFace) {
item.remove();
return;
}
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);
item.dataset.rackWidth = String(device.width);
item.dataset.horizontalPosition = String(device.horizontal_position);
return;
}
setAttribute(item, 'gs-w', columns);
setAttribute(item, 'gs-x', 0);
item.dataset.rackWidth = '1';
item.dataset.horizontalPosition = '1';
});
nativeItemsByFace[face] = nativeItemsById;
});
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';
if (!nativeItemsByFace[face].has(String(device.id))) {
addWidget(device, face, face);
}
if (device.full_depth) {
const oppositeFace = face === 'front' ? 'rear' : 'front';
if (!nativeItemsByFace[oppositeFace].has(String(device.id))) {
addWidget(device, oppositeFace, '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,387 @@
(() => {
'use strict';
const placementSource = document.getElementById('netbox-utilities-topology-rack-width-data');
if (!placementSource) return;
let payload;
try {
payload = JSON.parse(placementSource.textContent);
} catch (error) {
console.error('NetBox Utilities could not read the Topology Views rack widths.', error);
return;
}
if (!Array.isArray(payload) && (payload?.schema_version !== 3 || payload?.complete !== true)) {
console.warn('NetBox Utilities received an incomplete Topology Views payload and kept the native layout.');
return;
}
if (!Array.isArray(payload) && payload?.status && payload.status !== 'ready') {
console.warn('NetBox Utilities is leaving Topology Views in its native layout.', payload.status);
return;
}
const placementData = Array.isArray(payload) ? payload : payload?.devices;
if (!Array.isArray(placementData)) {
console.error('NetBox Utilities received invalid Topology Views rack width data.');
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,
source: String(value.width_source || 'stored'),
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 (!rackCards.length && !placementData.length) return;
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.dataset.rackWidthSource = placement.source;
device.style.setProperty('--netbox-utilities-rack-device-left', `${placement.leftPercent}%`);
device.style.setProperty('--netbox-utilities-rack-device-width', `${placement.widthPercent}%`);
});
const rackDataSource = document.getElementById('rack-export-data');
if (!rackDataSource) return;
let rackData;
try {
rackData = JSON.parse(rackDataSource.textContent);
} catch (error) {
console.error('NetBox Utilities could not read the Topology Views rack export data.', error);
return;
}
const rackExportCompatible = Array.isArray(rackData) && rackData.length > 0 && rackData.every(rack => {
if (!rack || !/^\d+$/.test(String(rack.id)) || !Array.isArray(rack.devices)) return false;
if (!Number.isFinite(Number(rack.u_height)) || !Number.isFinite(Number(rack.starting_unit))) return false;
const card = document.querySelector(`.rack-card[data-rack-id="${rack.id}"]`);
if (!card || !Number.isFinite(Number(card.dataset.rackUnits))) return false;
return true;
});
if (!rackExportCompatible) {
console.warn('NetBox Utilities left Topology Views exports unchanged because their schema is not compatible.');
return;
}
const xmlEscape = value => String(value ?? '').replace(/[&<>"']/g, character => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&apos;',
})[character]);
const safeName = value => String(value || 'export')
.replace(/[^a-z0-9._-]+/gi, '-')
.replace(/^-|-$/g, '');
const numberOr = (value, fallback) => {
const number = Number.parseFloat(value);
return Number.isFinite(number) ? number : fallback;
};
const activeNetBoxTheme = () => {
for (const element of [document.body, document.documentElement]) {
const match = getComputedStyle(element).backgroundColor.match(/[\d.]+/g);
if (match && match.length >= 3 && (match.length < 4 || Number(match[3]) > 0)) {
const [red, green, blue] = match.map(Number);
return (.2126 * red + .7152 * green + .0722 * blue) < 140 ? 'dark' : 'light';
}
}
return document.body.dataset.bsTheme || document.documentElement.dataset.bsTheme || 'light';
};
const exportPalette = () => activeNetBoxTheme() === 'dark' ? {
frame: '#52606d',
grid: '#667382',
heading: '#8fc8ff',
muted: '#a9b7c6',
deviceFill: '#162e45',
deviceText: '#ffffff',
deviceSubtext: '#cad5df',
} : {
frame: '#718096',
grid: '#8b99a8',
heading: '#0b69b7',
muted: '#52606d',
deviceFill: '#eef6ff',
deviceText: '#132238',
deviceSubtext: '#52606d',
};
const exportPlacement = device => {
const width = Number.parseInt(device.dataset.rackWidth || '1', 10);
const horizontalPosition = Number.parseInt(device.dataset.horizontalPosition || '1', 10);
if (![1, 2, 3, 4].includes(width) || horizontalPosition < 1 || horizontalPosition > width) {
return {width: 1, horizontalPosition: 1};
}
return {width, horizontalPosition};
};
const renderedRackData = racks => racks.map(rack => {
const card = document.querySelector(`.rack-card[data-rack-id="${rack.id}"]`);
if (!card) return rack;
const rackUnits = numberOr(card.dataset.rackUnits, numberOr(rack.u_height, 1));
const devices = Array.from(card.querySelectorAll('.rack-device')).map(device => {
const placement = exportPlacement(device);
return {
name: device.dataset.deviceName || device.querySelector('span')?.textContent || 'Gerät',
type: device.dataset.deviceType || '',
position: numberOr(device.dataset.position, numberOr(rack.starting_unit, 1)),
height: Math.max(numberOr(device.dataset.deviceHeight, 100 / rackUnits), 1),
face: device.dataset.face === 'rear' ? 'rear' : 'front',
color: device.dataset.deviceColor || '#1685fc',
width: placement.width,
horizontalPosition: placement.horizontalPosition,
};
});
return {...rack, devices};
});
const horizontalGeometry = (faceX, faceWidth, device) => {
const width = [1, 2, 3, 4].includes(device.width) ? device.width : 1;
const horizontalPosition = device.horizontalPosition >= 1 && device.horizontalPosition <= width
? device.horizontalPosition
: 1;
const slotWidth = faceWidth / width;
return {
x: faceX + (horizontalPosition - 1) * slotWidth + 3,
width: Math.max(slotWidth - 6, 1),
};
};
const svgDocument = (width, height, content) =>
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">${content}</svg>`;
const drawioDocument = cells =>
`<mxfile host="app.diagrams.net" type="device"><diagram name="Rack export"><mxGraphModel page="0" background="none" grid="1" guides="1" connect="1"><root><mxCell id="0"/><mxCell id="1" parent="0"/>${cells}</root></mxGraphModel></diagram></mxfile>`;
const buildRackDrawio = racks => {
const palette = exportPalette();
const unit = 22;
const faceWidth = 250;
const labelWidth = 28;
const faceGap = 28;
const rackGap = 44;
const header = 55;
const rackWidth = labelWidth + faceWidth * 2 + faceGap;
let cells = '';
let cellId = 2;
const vertex = (value, style, x, y, width, height) => {
cells += `<mxCell id="${cellId++}" value="${xmlEscape(value)}" style="${style}" vertex="1" parent="1"><mxGeometry x="${x}" y="${y}" width="${width}" height="${height}" as="geometry"/></mxCell>`;
};
racks.forEach((rack, rackIndex) => {
const rackUnits = numberOr(rack.u_height, 1);
const baseX = 10 + rackIndex * (rackWidth + rackGap);
const rackHeight = rackUnits * unit;
const startingUnit = numberOr(rack.starting_unit, 1);
vertex(rack.name, `text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;fontSize=16;fontStyle=1;fontColor=${palette.heading};`, baseX, 0, rackWidth, 25);
vertex(`${rack.site}${rack.location ? ` · ${rack.location}` : ''} · ${rackUnits}U`, `text;html=1;strokeColor=none;fillColor=none;align=left;fontSize=10;fontColor=${palette.muted};`, baseX, 24, rackWidth, 20);
['front', 'rear'].forEach((face, faceIndex) => {
const faceX = baseX + labelWidth + faceIndex * (faceWidth + faceGap);
vertex(face === 'front' ? 'VORDERSEITE' : 'RÜCKSEITE', `text;html=1;strokeColor=none;fillColor=none;align=center;fontSize=10;fontColor=${palette.muted};`, faceX, 38, faceWidth, 17);
vertex('', `rounded=0;html=1;fillColor=none;strokeColor=${palette.frame};strokeWidth=4;`, faceX, header, faceWidth, rackHeight);
for (let row = 0; row < rackUnits; row += 1) {
const y = header + row * unit;
const unitLabel = rack.desc_units ? startingUnit + row : startingUnit + rackUnits - 1 - row;
if (faceIndex === 0) {
vertex(String(unitLabel), `text;html=1;strokeColor=none;fillColor=none;align=right;fontSize=8;fontColor=${palette.muted};`, faceX - labelWidth, y, labelWidth - 4, unit);
}
}
rack.devices.filter(device => device.face === face).forEach(device => {
const positionOffset = device.position - startingUnit;
const y = rack.desc_units
? header + positionOffset * unit
: header + rackHeight - (positionOffset + device.height) * unit;
const height = Math.max(device.height * unit, 4);
const geometry = horizontalGeometry(faceX, faceWidth, device);
const value = height >= 31
? `${device.name}<br><font style="font-size:9px">U${device.position} · ${device.type}</font>`
: device.name;
vertex(value, `rounded=1;html=1;whiteSpace=wrap;overflow=hidden;align=left;verticalAlign=top;spacingLeft=5;spacingTop=2;fillColor=${palette.deviceFill};strokeColor=${device.color};strokeWidth=2;fontColor=${palette.deviceText};fontSize=11;`, geometry.x, y + 1, geometry.width, height - 2);
});
});
});
return drawioDocument(cells);
};
const buildRackSvg = racks => {
const palette = exportPalette();
const unit = 22;
const faceWidth = 250;
const labelWidth = 28;
const faceGap = 28;
const rackGap = 44;
const header = 55;
const rackWidth = labelWidth + faceWidth * 2 + faceGap;
const maxUnits = Math.max(...racks.map(rack => numberOr(rack.u_height, 1)), 1);
const width = racks.length * rackWidth + Math.max(0, racks.length - 1) * rackGap + 20;
const height = header + maxUnits * unit + 25;
let definitions = '';
let content = '';
racks.forEach((rack, rackIndex) => {
const rackUnits = numberOr(rack.u_height, 1);
const baseX = 10 + rackIndex * (rackWidth + rackGap);
const rackHeight = rackUnits * unit;
const startingUnit = numberOr(rack.starting_unit, 1);
content += `<text x="${baseX}" y="20" fill="${palette.heading}" font-family="sans-serif" font-size="16" font-weight="700">${xmlEscape(rack.name)}</text>`;
content += `<text x="${baseX}" y="39" fill="${palette.muted}" font-family="sans-serif" font-size="11">${xmlEscape(rack.site)}${rack.location ? ` · ${xmlEscape(rack.location)}` : ''} · ${rackUnits}U</text>`;
['front', 'rear'].forEach((face, faceIndex) => {
const faceX = baseX + labelWidth + faceIndex * (faceWidth + faceGap);
content += `<text x="${faceX + faceWidth / 2}" y="51" text-anchor="middle" fill="${palette.muted}" font-family="sans-serif" font-size="10">${face === 'front' ? 'VORDERSEITE' : 'RÜCKSEITE'}</text>`;
content += `<rect x="${faceX}" y="${header}" width="${faceWidth}" height="${rackHeight}" fill="none" stroke="${palette.frame}" stroke-width="4"/>`;
for (let row = 0; row < rackUnits; row += 1) {
const y = header + row * unit;
const unitLabel = rack.desc_units ? startingUnit + row : startingUnit + rackUnits - 1 - row;
content += `<line x1="${faceX}" y1="${y}" x2="${faceX + faceWidth}" y2="${y}" stroke="${palette.grid}" stroke-opacity=".35"/>`;
if (faceIndex === 0) {
content += `<text x="${faceX - 6}" y="${y + 15}" text-anchor="end" fill="${palette.muted}" font-family="sans-serif" font-size="9">${unitLabel}</text>`;
}
}
rack.devices.filter(device => device.face === face).forEach((device, deviceIndex) => {
const positionOffset = device.position - startingUnit;
const y = rack.desc_units
? header + positionOffset * unit
: header + rackHeight - (positionOffset + device.height) * unit;
const deviceHeight = Math.max(device.height * unit, 4);
const geometry = horizontalGeometry(faceX, faceWidth, device);
const clipId = `netbox-utilities-rack-${rackIndex}-${faceIndex}-${deviceIndex}`;
definitions += `<clipPath id="${clipId}"><rect x="${geometry.x}" y="${y + 1}" width="${geometry.width}" height="${deviceHeight - 2}"/></clipPath>`;
content += `<g clip-path="url(#${clipId})">`;
content += `<rect x="${geometry.x}" y="${y + 1}" width="${geometry.width}" height="${deviceHeight - 2}" rx="3" fill="${palette.deviceFill}" stroke="${xmlEscape(device.color)}" stroke-width="2"/>`;
if (deviceHeight >= 13) {
content += `<text x="${geometry.x + 6}" y="${y + Math.min(15, deviceHeight - 4)}" fill="${palette.deviceText}" font-family="sans-serif" font-size="11" font-weight="600">${xmlEscape(device.name)}</text>`;
}
if (deviceHeight >= 31) {
content += `<text x="${geometry.x + 6}" y="${y + 28}" fill="${palette.deviceSubtext}" font-family="sans-serif" font-size="9">U${device.position} · ${xmlEscape(device.type)}</text>`;
}
content += '</g>';
});
});
});
const svg = svgDocument(width, height, `<defs>${definitions}</defs>${content}`);
return {svg, drawio: buildRackDrawio(racks), width, height};
};
const downloadBlob = (blob, filename) => {
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = filename;
link.click();
setTimeout(() => URL.revokeObjectURL(link.href), 1000);
};
const exportGraphic = (graphic, format, filename) => {
if (format === 'svg') {
downloadBlob(new Blob([graphic.svg], {type: 'image/svg+xml'}), `${filename}.svg`);
return;
}
if (format === 'drawio') {
downloadBlob(new Blob([graphic.drawio], {type: 'application/xml'}), `${filename}.drawio`);
return;
}
const image = new Image();
const url = URL.createObjectURL(new Blob([graphic.svg], {type: 'image/svg+xml'}));
image.onload = () => {
const scale = 2;
const canvas = document.createElement('canvas');
canvas.width = graphic.width * scale;
canvas.height = graphic.height * scale;
const context = canvas.getContext('2d');
context.clearRect(0, 0, canvas.width, canvas.height);
context.scale(scale, scale);
context.drawImage(image, 0, 0);
canvas.toBlob(blob => downloadBlob(blob, `${filename}.png`), 'image/png');
URL.revokeObjectURL(url);
};
image.onerror = () => URL.revokeObjectURL(url);
image.src = url;
};
const selectedRacks = button => button.dataset.scope === 'all'
? rackData
: rackData.filter(rack => String(rack.id) === button.dataset.scope);
const containsPartialWidthDevice = racks => racks.some(rack =>
document.querySelector(`.rack-card[data-rack-id="${rack.id}"] .netbox-utilities-partial-width`));
document.addEventListener('click', event => {
const target = event.target instanceof Element ? event.target : event.target?.parentElement;
const button = target?.closest('.rack-export');
if (!button) return;
const selection = selectedRacks(button);
if (!selection.length || !containsPartialWidthDevice(selection)) return;
try {
const racks = renderedRackData(selection);
const graphic = buildRackSvg(racks);
const filename = button.dataset.scope === 'all'
? 'racks-gesamt'
: `rack-${safeName(racks[0]?.name)}`;
exportGraphic(graphic, button.dataset.format, filename);
event.preventDefault();
event.stopImmediatePropagation();
} catch (error) {
// Let Topology Views' original click handler provide its normal export fallback.
console.error('NetBox Utilities could not create the width-aware rack export.', error);
}
}, true);
})();
+50 -3
View File
@@ -5,7 +5,12 @@ from netbox.plugins import PluginTemplateExtension
from tenancy.models import Tenant, TenantGroup
from . import __version__
from .models import NavigationPreference
from .models import (
CableVLANAssignment,
DeviceRackPlacement,
NavigationPreference,
WirelessLinkVLANAssignment,
)
from .navigation_helpers import (
SIDEBAR_WIDTH_DEFAULT,
SIDEBAR_WIDTH_MAX,
@@ -14,7 +19,7 @@ from .navigation_helpers import (
normalize_preferences,
normalize_sidebar_width,
)
from .runtime import navigation_customization_enabled, tenant_filter_enabled
from .runtime import connection_vlans_enabled, navigation_customization_enabled, tenant_filter_enabled
class UtilitiesGlobalContent(PluginTemplateExtension):
@@ -99,5 +104,47 @@ class DeviceUtilitiesContent(PluginTemplateExtension):
return ""
return self.render("netbox_utilities/device_bulk_module_button.html")
def right_page(self):
placement = DeviceRackPlacement.objects.filter(device=self.context["object"]).first()
if placement is None:
return ""
return self.render(
"netbox_utilities/device_rack_width_panel.html",
{
"rack_width": f"1/{placement.width}",
"horizontal_position": placement.horizontal_position,
},
)
template_extensions = [UtilitiesGlobalContent, DeviceUtilitiesContent]
class ConnectionVLANContent(PluginTemplateExtension):
models = ["dcim.cable", "wireless.wirelesslink"]
def right_page(self):
if not connection_vlans_enabled():
return ""
connection = self.context["object"]
if connection._meta.label_lower == "dcim.cable":
assignment_model = CableVLANAssignment
lookup = {"cable": connection}
else:
assignment_model = WirelessLinkVLANAssignment
lookup = {"wireless_link": connection}
assignment = assignment_model.objects.filter(**lookup).first()
if assignment is None:
return ""
from ipam.models import VLAN
visible_vlans = VLAN.objects.restrict(self.context["request"].user, "view").filter(
pk__in=assignment.vlans.values("pk")
)
return self.render(
"netbox_utilities/connection_vlans_panel.html",
{"connection_vlans": visible_vlans},
)
template_extensions = [UtilitiesGlobalContent, DeviceUtilitiesContent, ConnectionVLANContent]
@@ -0,0 +1,5 @@
{% extends 'dcim/cable_edit.html' %}
{% block form %}
{% include 'netbox_utilities/cable_edit_form.html' %}
{% endblock %}
@@ -0,0 +1,123 @@
{% load static %}
{% load helpers %}
{% load form_helpers %}
{% load i18n %}
{% for field in form.hidden_fields %}
{{ field }}
{% endfor %}
{# A side termination #}
<div class="field-group mb-5">
<div class="row">
<h2 class="col-9 offset-3">{% trans "A Side" %}</h2>
</div>
{% render_field form.a_terminations_type %}
{% if 'termination_a_device' in form.fields %}
{% render_field form.termination_a_device %}
{% endif %}
{% if 'termination_a_powerpanel' in form.fields %}
{% render_field form.termination_a_powerpanel %}
{% endif %}
{% if 'termination_a_circuit' in form.fields %}
{% render_field form.termination_a_circuit %}
{% endif %}
{% if 'a_terminations' in form.fields %}
{% render_field form.a_terminations %}
{% endif %}
</div>
{# B side termination and connection VLANs #}
<div class="field-group mb-5">
<div class="row">
<h2 class="col-9 offset-3">{% trans "B Side" %}</h2>
</div>
{% render_field form.b_terminations_type %}
{% if 'termination_b_device' in form.fields %}
{% render_field form.termination_b_device %}
{% endif %}
{% if 'termination_b_powerpanel' in form.fields %}
{% render_field form.termination_b_powerpanel %}
{% endif %}
{% if 'termination_b_circuit' in form.fields %}
{% render_field form.termination_b_circuit %}
{% endif %}
{% if 'b_terminations' in form.fields %}
{% render_field form.b_terminations %}
{% endif %}
{% if form.utilities_vlan_group %}
{% render_field form.utilities_vlan_group %}
{% endif %}
{% if form.utilities_vlans %}
{% render_field form.utilities_vlans %}
{% endif %}
</div>
{# Cable attributes #}
<div class="field-group mb-5">
<div class="row">
<h2 class="col-9 offset-3">{% trans "Cable" %}</h2>
</div>
{% render_field form.status %}
{% render_field form.profile %}
{% render_field form.type %}
{% render_field form.bundle %}
{% render_field form.label %}
{% render_field form.description %}
{% render_field form.color %}
<div class="row mb-3{% if form.length.errors or form.length_unit.errors %} has-errors{% endif %}">
<label class="col-sm-3 col-form-label text-lg-end">{{ form.length.label }}</label>
<div class="col-md-5">
{% render_field_with_aria form.length %}
{% if form.length.errors %}
<div class="form-text text-danger" id="{{ form.length.auto_id }}_errors" role="alert">
{% for error in form.length.errors %}{{ error }}{% if not forloop.last %}<br />{% endif %}{% endfor %}
</div>
{% endif %}
</div>
<div class="col-md-4">
{% render_field_with_aria form.length_unit %}
{% if form.length_unit.errors %}
<div class="form-text text-danger" id="{{ form.length_unit.auto_id }}_errors" role="alert">
{% for error in form.length_unit.errors %}{{ error }}{% if not forloop.last %}<br />{% endif %}{% endfor %}
</div>
{% endif %}
</div>
</div>
{% render_field form.tags %}
</div>
<div class="field-group mb-5">
<div class="row">
<h2 class="col-9 offset-3">{% trans "Tenancy" %}</h2>
</div>
{% render_field form.tenant_group %}
{% render_field form.tenant %}
</div>
<div class="field-group mb-5">
<div class="row">
<h2 class="col-9 offset-3">{% trans "Ownership" %}</h2>
</div>
{% render_field form.owner_group %}
{% render_field form.owner %}
</div>
{% if form.custom_fields %}
<div class="field-group mb-5">
<div class="row">
<h2 class="col-9 offset-3">{% trans "Custom Fields" %}</h2>
</div>
{% render_custom_fields form %}
</div>
{% endif %}
<div class="field-group mb-5">
<h2 class="text-center">{% trans "Comments" %}</h2>
{% render_field form.comments %}
</div>
{# Meta fields #}
<div class="bg-primary-subtle border border-primary rounded-1 pt-3 px-3 mb-3">
{% render_field form.changelog_message %}
</div>
@@ -0,0 +1,12 @@
<div class="card">
<h2 class="card-header">VLANs der Verbindung</h2>
<div class="card-body">
{% if connection_vlans %}
{% for vlan in connection_vlans %}
<a href="{{ vlan.get_absolute_url }}" class="badge text-bg-primary me-1 mb-1">{{ vlan }}</a>
{% endfor %}
{% else %}
<span class="text-muted">Keine VLANs zugeordnet</span>
{% endif %}
</div>
</div>
@@ -0,0 +1,5 @@
{% extends 'dcim/device_edit.html' %}
{% block form %}
{% include 'netbox_utilities/device_edit_form.html' %}
{% endblock form %}
@@ -0,0 +1,130 @@
{% load form_helpers %}
{% load i18n %}
{% for field in form.hidden_fields %}
{{ field }}
{% endfor %}
<div class="field-group my-5">
<div class="row">
<h2 class="col-9 offset-3">{% trans "Device" %}</h2>
</div>
{% render_field form.name %}
{% render_field form.role %}
{% render_field form.description %}
{% render_field form.tags %}
</div>
<div class="field-group my-5">
<div class="row">
<h2 class="col-9 offset-3">{% trans "Hardware" %}</h2>
</div>
{% render_field form.device_type %}
{% render_field form.airflow %}
{% render_field form.serial %}
{% render_field form.asset_tag %}
</div>
<div class="field-group my-5">
<div class="row">
<h2 class="col-9 offset-3">{% trans "Location" %}</h2>
</div>
{% render_field form.site %}
{% render_field form.location %}
{% render_field form.rack %}
{% if object.device_type.is_child_device and object.parent_bay %}
<div class="row mb-3">
<label for="id_parent_device" class="col-sm-3 col-form-label">{% trans "Parent Device" %}</label>
<div class="col">
<input id="id_parent_device" class="form-control" value="{{ object.parent_bay.device }}" disabled />
</div>
</div>
<div class="row mb-3">
<label for="id_parent_bay" class="col-sm-3 col-form-label">{% trans "Parent Bay" %}</label>
<div class="col">
<div class="input-group">
<input id="id_parent_bay" class="form-control" value="{{ object.parent_bay.name }}" disabled />
<a href="{% url 'dcim:devicebay_depopulate' pk=object.parent_bay.pk %}" title="{% trans "Remove" %}" aria-label="{% trans "Remove" %}" class="btn btn-danger d-inline-flex align-items-center">
<i class="mdi mdi-close-thick" aria-hidden="true"></i>&nbsp;{% trans "Remove" %}
</a>
</div>
</div>
</div>
{% else %}
{% render_field form.face %}
{% render_field form.position %}
{% render_field form.utilities_rack_width %}
{% render_field form.utilities_horizontal_position %}
{% render_field form.latitude %}
{% render_field form.longitude %}
{% endif %}
</div>
<div class="field-group my-5">
<div class="row">
<h2 class="col-9 offset-3">{% trans "Management" %}</h2>
</div>
{% render_field form.status %}
{% render_field form.platform %}
{% render_field form.config_template %}
{% if object.pk %}
{% render_field form.primary_ip4 %}
{% render_field form.primary_ip6 %}
{% render_field form.oob_ip %}
{% endif %}
</div>
<div class="field-group my-5">
<div class="row">
<h2 class="col-9 offset-3">{% trans "Virtualization" %}</h2>
</div>
{% render_field form.cluster %}
</div>
<div class="field-group my-5">
<div class="row">
<h2 class="col-9 offset-3">{% trans "Tenancy" %}</h2>
</div>
{% render_field form.tenant_group %}
{% render_field form.tenant %}
</div>
<div class="field-group my-5">
<div class="row">
<h2 class="col-9 offset-3">{% trans "Virtual Chassis" %}</h2>
</div>
{% render_field form.virtual_chassis %}
{% render_field form.vc_position %}
{% render_field form.vc_priority %}
</div>
<div class="field-group mb-5">
<div class="row">
<h2 class="col-9 offset-3">{% trans "Ownership" %}</h2>
</div>
{% render_field form.owner_group %}
{% render_field form.owner %}
</div>
{% if form.custom_fields %}
<div class="field-group my-5">
<div class="row">
<h2 class="col-9 offset-3">{% trans "Custom Fields" %}</h2>
</div>
{% render_custom_fields form %}
</div>
{% endif %}
<div class="field-group my-5">
<h2 class="text-center">{% trans "Local Config Context Data" %}</h2>
{% render_field form.local_context_data %}
</div>
<div class="field-group mb-5">
{% render_field form.comments %}
</div>
<div class="bg-primary-subtle border border-primary rounded-1 pt-3 px-3 mb-3">
{% render_field form.changelog_message %}
</div>
@@ -0,0 +1,13 @@
<div class="card">
<h2 class="card-header">Rackbreite</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">Breite</th>
<td>{{ rack_width }} der Rackbreite</td>
</tr>
<tr>
<th scope="row">Position von links</th>
<td>{{ horizontal_position }}</td>
</tr>
</table>
</div>
@@ -0,0 +1,58 @@
{% extends "netbox_reorder_rack/rack.html" %}
{% load perms %}
{% load rack %}
{% load i18n %}
{% load static %}
{% block head %}
{{ block.super }}
{{ 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>
{% endblock %}
{% block content %}
<div class="row">
<div class="d-flex justify-content-end btn-list mb-2">
<select class="btn btn-outline-secondary no-ts rack-view" id="view-selector">
<option value="images-and-labels"{% if images and labels %} selected{% endif %}>{% trans "Images and Labels" %}</option>
<option value="images-only"{% if images and not labels %} selected{% endif %}>{% trans "Images only" %}</option>
<option value="labels-only"{% if labels and not images %} selected{% endif %}>{% trans "Labels only" %}</option>
</select>
<button class="btn btn-success" id="saveButton" disabled>Save</button>
</div>
</div>
<div class="container mt-2">
<div class="row">
<div class="col-lg-4 col-md-6 col-sm-12 text-center">
<h4>Front</h4>
{% include "netbox_utilities/reorder_rack_grid.html" with face="front" grid_id=0 %}
</div>
<div class="col-lg-4 col-md-6 col-sm-12 text-center">
<h4>Back</h4>
{% include "netbox_utilities/reorder_rack_grid.html" with face="rear" grid_id=1 %}
</div>
<div class="col-lg-4 col-md-6 col-sm-12 text-center">
<h4>Non-Racked Devices</h4>
<div class="d-flex justify-content-center">
<div class="grid-stack rack netbox-utilities-reorder-grid" id="grid-other" gs-column="12" gs-max-row="150"
gs-min-row="{{ object.u_height|mul:2 }}" data-grid-id="2">
{% for device in non_racked %}
<div class="grid-stack-item" gs-w="12" gs-h="{{ device.device_type.u_height|mul:2 }}"
gs-x="0" gs-id="{{ device.pk }}" data-item-face="none"
data-item-color="{{ device.role.color|default:'1685fc' }}"
data-item-text-color="{% if device.role.color %}{{ device.role.color|text_color }}{% else %}000000{% endif %}"
data-full-depth="{{ device.device_type.is_full_depth }}"
gs-locked="{% if not request.user|can_change:device %}true{% else %}false{% endif %}"
gs-no-move="{% if not request.user|can_change:device %}true{% else %}false{% endif %}">
<div class="grid-stack-item-content"
style="{% if images and device.device_type.front_image %}background-image: url('{{ device.device_type.front_image.url }}'); background-size: {{ unit_width }}px;{% else %}background-color: #{{ device.role.color|default:'1685fc' }};{% endif %} color: #{% if device.role.color %}{{ device.role.color|text_color }}{% else %}000000{% endif %}">
{% if images and device.device_type.front_image %}{% if labels %}{{ device.name|default:device.device_type }}{% endif %}{% else %}{{ device.name|default:device.device_type }}{% endif %}
</div>
</div>
{% endfor %}
</div>
</div>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,40 @@
{% load rack %}
<div class="d-flex justify-content-center">
<div class="rack_front d-flex">
<ul class="unit_numbers">
{% for unit in object.units %}
{% if unit|rack_unit %}<li>{{ unit|rack_unit_to_int }}</li>{% else %}<li></li>{% endif %}
{% endfor %}
</ul>
<div class="grid-stack rack netbox-utilities-reorder-grid" id="grid-{{ face }}" gs-column="12"
gs-max-row="{{ object.u_height|mul:2 }}" gs-min-row="{{ object.u_height|mul:2 }}"
data-grid-id="{{ grid_id }}">
{% for device in reorder_devices %}
{% if device.face == face or device.full_depth %}
<div class="grid-stack-item" gs-w="{{ device.grid_width }}" gs-h="{{ device.grid_height }}"
gs-x="{{ device.grid_x }}" gs-y="{{ device.grid_y }}" gs-id="{{ device.id }}"
gs-locked="{% if device.locked %}true{% else %}false{% endif %}"
gs-no-move="{% if device.locked %}true{% else %}false{% endif %}"
data-item-color="{{ device.color }}" data-item-text-color="{{ device.text_color }}"
data-full-depth="{% if device.full_depth %}True{% else %}False{% endif %}"
data-item-face="{% if device.face == face %}{{ face }}{% else %}back{% endif %}"
data-rack-width="{{ device.width }}"
data-horizontal-position="{{ device.horizontal_position }}">
{% if device.face == face %}
<div class="grid-stack-item-content"
style="{% if images and device.front_image %}background-image: url('{{ device.front_image }}'); background-size: {{ unit_width }}px; color: #{{ device.text_color }}{% else %}background-color: #{{ device.color }}; color: #{{ device.text_color }}{% endif %}">
{% if images and device.front_image %}{% if labels %}{{ device.label }}{% endif %}{% else %}{{ device.label }}{% endif %}
</div>
{% else %}
<div class="grid-stack-item-content device_rear"
style="{% if images and device.rear_image %}background-image: url('{{ device.rear_image }}'); background-size: {{ unit_width }}px; color: #{{ device.text_color }}{% else %}color: #000000{% endif %}">
{% if images and device.rear_image %}{% if labels %}{{ device.label }}{% endif %}{% else %}{{ device.label }}{% endif %}
</div>
{% endif %}
</div>
{% endif %}
{% endfor %}
</div>
<ul class="indexes"></ul>
</div>
</div>
+9 -9
View File
@@ -25,16 +25,20 @@ def tenant_id_from_object(obj, depth=0):
if isinstance(obj, Tenant):
return obj.pk
field_names = {field.name for field in obj._meta.get_fields()}
if "tenant" in field_names and getattr(obj, "tenant_id", None):
meta = getattr(obj, "_meta", None)
if meta is None or not hasattr(meta, "get_fields"):
return None
fields = {field.name: field for field in meta.get_fields()}
if "tenant" in fields and getattr(obj, "tenant_id", None):
return obj.tenant_id
for relation in PARENT_RELATIONS:
if relation not in field_names:
field = fields.get(relation)
if field is None or getattr(field, "one_to_many", False) or getattr(field, "many_to_many", False):
continue
try:
parent = getattr(obj, relation, None)
except (ObjectDoesNotExist, ValueError):
except (AttributeError, ObjectDoesNotExist, ValueError):
continue
if tenant_id := tenant_id_from_object(parent, depth + 1):
return tenant_id
@@ -76,11 +80,7 @@ def _related_objects(form, field_name):
def _is_cable(instance):
meta = getattr(instance, "_meta", None)
return bool(
meta
and getattr(meta, "app_label", None) == "dcim"
and getattr(meta, "model_name", None) == "cable"
)
return bool(meta and getattr(meta, "app_label", None) == "dcim" and getattr(meta, "model_name", None) == "cable")
def _instance_cable_terminations(instance):
@@ -0,0 +1,145 @@
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from django import forms
from django.forms.utils import ErrorDict
from django.template.loader import get_template
from django.test import SimpleTestCase
from netbox_utilities.connection_vlans import (
FORM_FIELD,
GROUP_FILTER_FIELD,
_build_connection_vlan_form,
include_connection_vlans_in_fieldsets,
)
from netbox_utilities.models import CableVLANAssignment, WirelessLinkVLANAssignment
class ConnectionVLANTest(SimpleTestCase):
def test_cable_vlan_field_is_rendered_below_b_side(self):
self.assertIsNotNone(get_template("netbox_utilities/cable_edit_form.html"))
template_path = Path(__file__).parents[1] / "templates" / "netbox_utilities" / "cable_edit_form.html"
template = template_path.read_text(encoding="utf-8")
b_side = template.index('{% trans "B Side" %}')
group_filter = template.index("{% render_field form.utilities_vlan_group %}")
vlan_field = template.index("{% render_field form.utilities_vlans %}")
cable_section = template.index('{% trans "Cable" %}')
self.assertLess(b_side, group_filter)
self.assertLess(group_filter, vlan_field)
self.assertLess(vlan_field, cable_section)
self.assertEqual(template.count("{% render_field form.utilities_vlan_group %}"), 1)
self.assertEqual(template.count("{% render_field form.utilities_vlans %}"), 1)
def test_assignment_models_are_tied_to_their_connection_and_vlans(self):
self.assertEqual(
CableVLANAssignment._meta.get_field("cable").remote_field.model._meta.label_lower, "dcim.cable"
)
self.assertEqual(
WirelessLinkVLANAssignment._meta.get_field("wireless_link").remote_field.model._meta.label_lower,
"wireless.wirelesslink",
)
self.assertEqual(CableVLANAssignment._meta.get_field("vlans").remote_field.model._meta.label_lower, "ipam.vlan")
self.assertEqual(
WirelessLinkVLANAssignment._meta.get_field("vlans").remote_field.model._meta.label_lower,
"ipam.vlan",
)
def test_connection_vlan_field_is_inserted_into_link_fieldset(self):
from utilities.forms.rendering import FieldSet
original = (FieldSet("status", "description", "tags", name="Link"),)
extended = include_connection_vlans_in_fieldsets(original)
self.assertEqual(
extended[0].items,
("status", "description", GROUP_FILTER_FIELD, FORM_FIELD, "tags"),
)
self.assertEqual(original[0].items, ("status", "description", "tags"))
def test_resolved_edit_views_expose_connection_vlan_field(self):
import dcim.forms
from dcim.models import Interface
from dcim.views import CableEditView
from wireless.views import WirelessLinkEditView
cable_form = dcim.forms.get_cable_form(Interface, Interface)
self.assertIn(GROUP_FILTER_FIELD, cable_form.base_fields)
self.assertIn(FORM_FIELD, cable_form.base_fields)
self.assertEqual(cable_form.base_fields[FORM_FIELD].query_params, {"group_id": f"${GROUP_FILTER_FIELD}"})
self.assertEqual(CableEditView.template_name, "netbox_utilities/cable_edit.html")
self.assertEqual(CableEditView.htmx_template_name, "netbox_utilities/cable_edit_form.html")
self.assertIn(GROUP_FILTER_FIELD, WirelessLinkEditView.form.base_fields)
self.assertIn(FORM_FIELD, WirelessLinkEditView.form.base_fields)
self.assertEqual(
WirelessLinkEditView.form.base_fields[FORM_FIELD].query_params,
{"group_id": f"${GROUP_FILTER_FIELD}"},
)
@patch("netbox_utilities.connection_vlans.CableVLANAssignment.objects")
def test_form_loads_and_saves_multiple_vlans(self, assignment_objects):
from dcim.models import Cable
from utilities.forms.rendering import FieldSet
class BareCableForm(forms.ModelForm):
fieldsets = (FieldSet("description", name="Cable"),)
class Meta:
model = Cable
fields = ()
vlan_a = SimpleNamespace(pk=10, group_id=5)
vlan_b = SimpleNamespace(pk=20, group_id=5)
assignment = MagicMock()
assignment.vlans.all.return_value = [vlan_a, vlan_b]
assignment_objects.filter.return_value.prefetch_related.return_value.first.return_value = assignment
assignment_objects.get_or_create.return_value = (assignment, False)
cable = Cable(pk=7)
form_class = _build_connection_vlan_form(BareCableForm, CableVLANAssignment, "cable")
with patch(
"netbox_utilities.runtime._get_database_settings",
return_value={"tenant_required": True},
):
form = form_class(instance=cable)
self.assertEqual(form.initial[FORM_FIELD], [vlan_a, vlan_b])
self.assertEqual(form.initial[GROUP_FILTER_FIELD], 5)
form._errors = ErrorDict()
form.cleaned_data = {FORM_FIELD: [vlan_a, vlan_b]}
with patch.object(Cable, "save"):
self.assertIs(form.save(), cable)
assignment_objects.get_or_create.assert_called_once_with(cable=cable)
assignment.vlans.set.assert_called_once_with([vlan_a, vlan_b])
@patch("netbox_utilities.connection_vlans.CableVLANAssignment.objects")
def test_empty_form_selection_removes_assignment(self, assignment_objects):
from dcim.models import Cable
class BareCableForm(forms.ModelForm):
fieldsets = ()
class Meta:
model = Cable
fields = ()
cable = Cable(pk=8)
form_class = _build_connection_vlan_form(BareCableForm, CableVLANAssignment, "cable")
with patch(
"netbox_utilities.runtime._get_database_settings",
return_value={"tenant_required": True},
):
form = form_class(instance=cable)
form._errors = ErrorDict()
form.cleaned_data = {FORM_FIELD: []}
with patch.object(Cable, "save"):
form.save()
assignment_objects.filter.assert_called_with(cable=cable)
assignment_objects.filter.return_value.delete.assert_called_once_with()
assignment_objects.get_or_create.assert_not_called()
+16
View File
@@ -1,6 +1,7 @@
from types import SimpleNamespace
from unittest.mock import patch
from django.http import HttpResponse
from django.test import RequestFactory, SimpleTestCase
from netbox_utilities.middleware import GlobalTenantFilterMiddleware
@@ -28,6 +29,21 @@ class GlobalTenantFilterMiddlewareTest(SimpleTestCase):
def setUp(self):
self.factory = RequestFactory()
@patch("netbox_utilities.topology_views.apply_topology_rack_widths")
@patch("netbox_utilities.middleware.GlobalTenantFilterMiddleware._get_selected_scope", return_value=None)
def test_processes_topology_widths_after_the_view_response(self, _scope, apply_widths):
native_response = HttpResponse("native topology")
final_response = HttpResponse("width-aware topology")
apply_widths.return_value = final_response
middleware = GlobalTenantFilterMiddleware(lambda _request: native_response)
request = self.factory.get("/plugins/netbox_topology_views/rack-elevation/?rack_id=3")
request.session = {}
result = middleware(request)
self.assertIs(result, final_response)
apply_widths.assert_called_once_with(request, native_response)
@patch("netbox_utilities.middleware.resolve")
def test_injects_and_overrides_tenant_id(self, mocked_resolve):
mocked_resolve.return_value = resolver_match(TenantFilterSet)
@@ -0,0 +1,285 @@
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from django.core.exceptions import ValidationError
from django.test import SimpleTestCase
from netbox_utilities.netbox_export import (
PATCH_MARKER,
PATCH_VERSION,
RACK_WIDTH_RECORD_KEY,
WidthAwareDeferredDevicePlacement,
_apply_rack_widths,
_defer_rack_width,
_serialize_rack_width,
_width_aware_device_placement_conflicts,
install_netbox_export_rack_width_support,
)
class FakeQuerySet(list):
def __init__(self, values=()):
super().__init__(values)
self.selected_related = []
def select_for_update(self):
return self
def select_related(self, *args):
self.selected_related.append(args)
return self
def filter(self, **kwargs):
values = self
if kwargs.get("position__isnull") is False:
values = [item for item in values if item.position is not None]
result = FakeQuerySet(values)
result.selected_related = self.selected_related
return result
def exclude(self, **kwargs):
pk = kwargs.get("pk")
result = FakeQuerySet(item for item in self if item.pk != pk)
result.selected_related = self.selected_related
return result
class FakeDevice:
_meta = SimpleNamespace(label_lower="dcim.device")
_default_manager = FakeQuerySet()
def __init__(self, pk, *, width=1, horizontal_position=1, position=None, face="front", full_depth=False):
self.pk = pk
self.rack_id = 1 if position is not None else None
self.position = position
self.face = face
self.device_type = SimpleNamespace(u_height=1, is_full_depth=full_depth)
if width != 1:
self.netbox_utilities_rack_placement = SimpleNamespace(
width=width,
horizontal_position=horizontal_position,
)
class NetBoxExportRackWidthTest(SimpleTestCase):
def test_connection_vlan_assignments_remain_exportable(self):
from netbox_export.services.graph import is_exportable_model
from netbox_utilities.models import CableVLANAssignment, WirelessLinkVLANAssignment
self.assertTrue(is_exportable_model(CableVLANAssignment))
self.assertTrue(is_exportable_model(WirelessLinkVLANAssignment))
def test_export_embeds_partial_and_full_rack_widths_on_devices(self):
partial = FakeDevice(1, width=2, horizontal_position=2)
full = FakeDevice(2)
original = lambda obj, *args, **kwargs: {"id": f"dcim.device:{obj.pk}"}
partial_record = _serialize_rack_width(original, partial)
full_record = _serialize_rack_width(original, full)
self.assertEqual(
partial_record[RACK_WIDTH_RECORD_KEY],
{"schema_version": 1, "width": 2, "horizontal_position": 2},
)
self.assertEqual(
full_record[RACK_WIDTH_RECORD_KEY],
{"schema_version": 1, "width": 1, "horizontal_position": 1},
)
def test_import_reads_width_metadata_without_breaking_legacy_archives(self):
native = SimpleNamespace(rack_spec={"ref": "dcim.rack:2"}, position=11, face="front")
original = MagicMock(return_value=({"fields": {}}, native))
record = {
"id": "dcim.device:334",
RACK_WIDTH_RECORD_KEY: {
"schema_version": 1,
"width": 2,
"horizontal_position": 2,
},
}
prepared, placement = _defer_rack_width(original, FakeDevice, record)
self.assertEqual(prepared, {"fields": {}})
self.assertEqual(
placement,
WidthAwareDeferredDevicePlacement(
rack_spec={"ref": "dcim.rack:2"},
position=11,
face="front",
width=2,
horizontal_position=2,
),
)
legacy_record = {"id": "dcim.device:334"}
self.assertIs(_defer_rack_width(original, FakeDevice, legacy_record)[1], native)
@patch("netbox_utilities.netbox_export.normalize_width_position", side_effect=ValidationError("invalid"))
def test_invalid_width_metadata_falls_back_to_native_import(self, _normalize):
native = SimpleNamespace(rack_spec=None, position=11, face="front")
original = MagicMock(return_value=({}, native))
record = {
"id": "dcim.device:334",
RACK_WIDTH_RECORD_KEY: {
"schema_version": 1,
"width": 9,
"horizontal_position": 9,
},
}
self.assertIs(_defer_rack_width(original, FakeDevice, record)[1], native)
@patch("netbox_utilities.netbox_export.DeviceRackPlacement.objects")
def test_import_conflicts_respect_horizontal_rack_positions(self, placement_objects):
left = FakeDevice(1, width=2, horizontal_position=1, position=11)
right = FakeDevice(2, width=2, horizontal_position=2)
right.rack_id = 1
FakeDevice._default_manager = FakeQuerySet([left])
stored_left = SimpleNamespace(device_id=1, width=2, horizontal_position=1)
placement_objects.select_for_update.return_value.filter.return_value = [stored_left]
original = MagicMock()
conflicts = _width_aware_device_placement_conflicts(original, right, object(), 11, "front")
self.assertEqual(conflicts, [])
original.assert_not_called()
self.assertEqual(FakeDevice._default_manager.selected_related, [("device_type",)])
placement_objects.select_for_update.return_value.filter.assert_called_with(device_id__in=[1])
overlapping = FakeDevice(3, width=2, horizontal_position=1)
overlapping.rack_id = 1
self.assertEqual(
_width_aware_device_placement_conflicts(original, overlapping, object(), 11, "front"),
[left],
)
@patch("netbox_utilities.netbox_export.DeviceRackPlacement.objects")
def test_import_persists_partial_width_and_removes_stale_full_width(self, placement_objects):
partial = FakeDevice(1)
full = FakeDevice(2, width=2, horizontal_position=2)
partial_placement = WidthAwareDeferredDevicePlacement(None, 11, "front", 2, 1)
full_placement = WidthAwareDeferredDevicePlacement(None, 12, "front", 1, 1)
placements = [("partial", partial_placement), ("full", full_placement)]
resolved = {"partial": partial, "full": full}
def native_apply(received, *_args, **_kwargs):
self.assertEqual(received, placements)
self.assertEqual(partial._netbox_utilities_rack_width, 2)
self.assertEqual(full._netbox_utilities_rack_width, 1)
placement_objects.update_or_create.assert_called_once_with(
device=partial,
defaults={"width": 2, "horizontal_position": 1},
)
placement_objects.filter.assert_called_once_with(device=full)
placement_objects.filter.return_value.delete.assert_called_once_with()
partial.rack_id = full.rack_id = 1
partial.position = 11
full.position = 12
return "saved"
result = _apply_rack_widths(native_apply, placements, resolved, object(), object())
self.assertEqual(result, "saved")
self.assertEqual(placement_objects.update_or_create.call_count, 2)
self.assertEqual(placement_objects.filter.call_count, 2)
self.assertEqual(placement_objects.filter.return_value.delete.call_count, 2)
@patch("netbox_utilities.netbox_export.DeviceRackPlacement.objects")
def test_import_preloads_all_partial_widths_before_native_placement(self, placement_objects):
left = FakeDevice(1)
right = FakeDevice(2)
placements = [
("left", WidthAwareDeferredDevicePlacement(None, 11, "front", 2, 1)),
("right", WidthAwareDeferredDevicePlacement(None, 11, "front", 2, 2)),
]
resolved = {"left": left, "right": right}
def native_apply(*_args, **_kwargs):
self.assertEqual(placement_objects.update_or_create.call_count, 2)
self.assertEqual(
[call.kwargs["device"] for call in placement_objects.update_or_create.call_args_list],
[left, right],
)
left.rack_id = right.rack_id = 1
left.position = right.position = 11
_apply_rack_widths(native_apply, placements, resolved, object(), object())
self.assertEqual(placement_objects.update_or_create.call_count, 4)
@patch("netbox_utilities.netbox_export.DeviceRackPlacement.objects")
def test_two_partial_devices_remain_in_the_same_unit_during_import(self, placement_objects):
left = FakeDevice(1)
right = FakeDevice(2)
FakeDevice._default_manager = FakeQuerySet([left, right])
placements = [
("left", WidthAwareDeferredDevicePlacement(None, 11, "front", 2, 1)),
("right", WidthAwareDeferredDevicePlacement(None, 11, "front", 2, 2)),
]
resolved = {"left": left, "right": right}
stored = {}
def store_width(*, device, defaults):
stored[device.pk] = SimpleNamespace(device_id=device.pk, **defaults)
def locked_widths(**kwargs):
return [stored[pk] for pk in kwargs["device_id__in"] if pk in stored]
placement_objects.update_or_create.side_effect = store_width
placement_objects.select_for_update.return_value.filter.side_effect = locked_widths
def native_apply(received, *_args, **_kwargs):
for record_id, placement in received:
device = resolved[record_id]
conflicts = _width_aware_device_placement_conflicts(
MagicMock(),
device,
object(),
placement.position,
placement.face,
)
self.assertEqual(conflicts, [])
device.rack_id = 1
device.position = placement.position
device.face = placement.face
_apply_rack_widths(native_apply, placements, resolved, object(), object())
self.assertEqual(left.position, 11)
self.assertEqual(right.position, 11)
@patch("netbox_utilities.netbox_export.import_module")
@patch("netbox_utilities.netbox_export.apps.get_app_config")
@patch("netbox_utilities.netbox_export.apps.is_installed", return_value=True)
def test_installer_patches_supported_netbox_export(self, _installed, get_config, import_module):
get_config.return_value = SimpleNamespace(version="0.3.11")
exporter = SimpleNamespace(serialize_object=lambda *args, **kwargs: {})
graph = SimpleNamespace(is_exportable_model=lambda model: False)
importer = SimpleNamespace(
_defer_device_placement=lambda *args, **kwargs: ({}, None),
_device_placement_conflicts=lambda *args, **kwargs: [],
_apply_device_placements=lambda *args, **kwargs: None,
)
import_module.side_effect = [exporter, graph, importer]
self.assertTrue(install_netbox_export_rack_width_support())
for module, name in (
(exporter, "serialize_object"),
(graph, "is_exportable_model"),
(importer, "_defer_device_placement"),
(importer, "_device_placement_conflicts"),
(importer, "_apply_device_placements"),
):
self.assertEqual(getattr(getattr(module, name), PATCH_MARKER), PATCH_VERSION)
@patch("netbox_utilities.netbox_export.import_module")
@patch("netbox_utilities.netbox_export.apps.get_app_config")
@patch("netbox_utilities.netbox_export.apps.is_installed", return_value=True)
def test_installer_ignores_unsupported_netbox_export(self, _installed, get_config, import_module):
get_config.return_value = SimpleNamespace(version="0.4.0")
self.assertFalse(install_netbox_export_rack_width_support())
import_module.assert_not_called()
@@ -0,0 +1,23 @@
from django.conf import settings
from django.contrib.auth.context_processors import PermWrapper
from django.contrib.auth.models import AnonymousUser
from django.test import RequestFactory, SimpleTestCase
from netbox_utilities.template_content import UtilitiesGlobalContent
class OptionalRackIntegrationHeadTest(SimpleTestCase):
def test_foreign_rack_adapters_are_not_loaded_by_the_global_head(self):
request = RequestFactory().get("/dcim/racks/3/reorder/")
request.user = AnonymousUser()
html = UtilitiesGlobalContent(
{
"request": request,
"settings": settings,
"csrf_token": "",
"perms": PermWrapper(request.user),
}
).head()
self.assertNotIn("netbox-utilities-reorder-rack-width-data", html)
self.assertNotIn("netbox-utilities-topology-rack-width-data", html)
+56 -1
View File
@@ -1,9 +1,17 @@
from importlib import import_module
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from django.test import SimpleTestCase
from netbox_utilities.patchpanel import is_patchpanel, pair_ports, port_identifier, retrace_patchpanel_paths
from netbox_utilities.patchpanel import (
_component_saved,
_wrap_component_container_save,
is_patchpanel,
pair_ports,
port_identifier,
retrace_patchpanel_paths,
)
def port(pk, name):
@@ -11,6 +19,53 @@ def port(pk, name):
class PatchpanelPairingTest(SimpleTestCase):
@patch("netbox_utilities.patchpanel.synchronize_patchpanel")
def test_component_edits_do_not_trigger_automation(self, synchronize):
component = SimpleNamespace(device_id=42)
_component_saved(None, component, created=False)
synchronize.assert_not_called()
@patch("netbox_utilities.patchpanel.synchronize_patchpanel")
def test_new_components_trigger_automation(self, synchronize):
component = SimpleNamespace(device_id=42)
_component_saved(None, component, created=True)
synchronize.assert_called_once_with(42)
@patch("netbox_utilities.patchpanel.synchronize_patchpanel")
def test_existing_device_save_does_not_trigger_automation(self, synchronize):
class Container:
def __init__(self, adding):
self._state = SimpleNamespace(adding=adding)
self.pk = 42
def save(self):
self._state.adding = False
return "saved"
_wrap_component_container_save(Container, lambda instance: instance.pk)
self.assertEqual(Container(adding=False).save(), "saved")
synchronize.assert_not_called()
self.assertEqual(Container(adding=True).save(), "saved")
synchronize.assert_called_once_with(42)
def test_existing_device_migrations_are_noops(self):
migration_names = (
"0004_patchpanel_port_mappings",
"0005_fix_patchpanel_port_number_mappings",
"0006_retrace_existing_patchpanel_paths",
)
for migration_name in migration_names:
with self.subTest(migration=migration_name):
module = import_module(f"netbox_utilities.migrations.{migration_name}")
self.assertEqual(module.Migration.operations, [])
def test_detects_patchpanel_role_case_insensitively(self):
self.assertTrue(is_patchpanel(SimpleNamespace(role=SimpleNamespace(name="Patchpanel"))))
self.assertTrue(is_patchpanel(SimpleNamespace(role=SimpleNamespace(name="PATCHPANEL"))))
+425
View File
@@ -0,0 +1,425 @@
from functools import wraps
from importlib import import_module
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock, call, patch
from django.conf import settings
from django.contrib.auth.context_processors import PermWrapper
from django.contrib.auth.models import AnonymousUser
from django.core.exceptions import ValidationError
from django.template.loader import render_to_string
from django.test import RequestFactory, SimpleTestCase
from django.urls import resolve, reverse
from netbox_utilities.rack_width import (
_cleanup_unracked_placement,
_install_rack_methods,
_rack_elevation_devices,
available_units_for_device,
effective_width_positions,
horizontal_interval,
include_rack_width_in_fieldsets,
intervals_cover_full_width,
intervals_overlap,
normalize_width_position,
placement_rectangles_overlap,
stage_width_position,
)
class RackWidthTest(SimpleTestCase):
def test_rack_width_javascript_synchronizes_netbox_tom_select(self):
script = (Path(__file__).parents[1] / "static" / "netbox_utilities" / "forms.js").read_text(encoding="utf-8")
self.assertIn("positionField.disabled = width === 1", script)
self.assertIn("positionField.tomselect.sync()", script)
def test_svg_devices_include_core_device_bay_annotation_and_optional_placement(self):
from dcim.choices import DeviceFaceChoices
from dcim.models import Rack
devices = _rack_elevation_devices(Rack(pk=3), DeviceFaceChoices.FACE_FRONT)
self.assertIn("devicebay_count", devices.query.annotations)
self.assertIn("netbox_utilities_rack_placement", devices.query.select_related)
self.assertIn("device_type", devices.query.select_related)
self.assertIn("manufacturer", devices.query.select_related["device_type"])
self.assertNotIn("netbox_utilities_rack_placement__isnull", str(devices.query))
def test_core_rack_units_remain_available_to_other_plugins(self):
from dcim.models import Rack
self.assertEqual(Rack.get_rack_units.__module__, "dcim.models.racks")
self.assertFalse(hasattr(Rack.get_rack_units, "__wrapped__"))
def test_replaces_legacy_rack_unit_wrapper_during_upgrade(self):
def native_rack_units(rack):
return rack
def native_available_units(rack, *args, **kwargs):
return rack, args, kwargs
@wraps(native_rack_units)
def legacy_rack_units(rack):
return native_rack_units(rack)
@wraps(native_available_units)
def legacy_available_units(rack, *args, **kwargs):
return native_available_units(rack, *args, **kwargs)
class LegacyRack:
get_rack_units = legacy_rack_units
get_available_units = legacy_available_units
_netbox_utilities_rack_width_installed = True
with patch.dict("sys.modules", {"dcim.models": SimpleNamespace(Rack=LegacyRack)}):
_install_rack_methods()
self.assertIs(LegacyRack.get_rack_units, native_rack_units)
self.assertIs(LegacyRack.get_available_units.__wrapped__, native_available_units)
self.assertEqual(LegacyRack._netbox_utilities_rack_width_version, 2)
def test_svg_draws_all_partial_devices_sharing_one_unit_exactly_once(self):
from dcim.svg.racks import RackElevationSVG
regular = SimpleNamespace(
pk=1,
rack_id=3,
face="front",
position=12,
device_type=SimpleNamespace(u_height=1),
netbox_utilities_rack_placement=None,
)
partial_right = SimpleNamespace(
pk=2,
rack_id=3,
face="front",
position=11,
device_type=SimpleNamespace(u_height=1),
netbox_utilities_rack_placement=SimpleNamespace(width=2, horizontal_position=2),
)
partial_left = SimpleNamespace(
pk=3,
rack_id=3,
face="front",
position=11,
device_type=SimpleNamespace(u_height=1),
netbox_utilities_rack_placement=SimpleNamespace(width=2, horizontal_position=1),
)
rack = SimpleNamespace(get_rack_units=MagicMock())
elevation = SimpleNamespace(
rack=rack,
unit_width=200,
unit_height=20,
permitted_device_ids={1, 2, 3},
drawing=SimpleNamespace(add=MagicMock()),
_get_device_coords=MagicMock(return_value=(10, 20)),
draw_device_front=MagicMock(),
draw_device_rear=MagicMock(),
)
with patch(
"netbox_utilities.rack_width._rack_elevation_devices",
return_value=[regular, partial_left, partial_right],
):
RackElevationSVG.draw_face(elevation, "front")
rack.get_rack_units.assert_not_called()
self.assertEqual(
elevation.draw_device_front.call_args_list,
[
call(regular, (10, 20), (200, 20)),
call(partial_left, (10.0, 20), (100.0, 20)),
call(partial_right, (110.0, 20), (100.0, 20)),
],
)
def test_infers_two_half_widths_when_legacy_devices_share_one_unit(self):
devices = [
SimpleNamespace(
pk=21,
rack_id=3,
face="front",
position=11,
device_type=SimpleNamespace(u_height=1),
netbox_utilities_rack_placement=None,
),
SimpleNamespace(
pk=22,
rack_id=3,
face="front",
position=11,
device_type=SimpleNamespace(u_height=1),
netbox_utilities_rack_placement=None,
),
]
self.assertEqual(
effective_width_positions(devices),
{
21: (2, 1, "inferred"),
22: (2, 2, "inferred"),
},
)
def test_infers_missing_position_next_to_stored_half_width(self):
devices = [
SimpleNamespace(
pk=31,
rack_id=3,
face="front",
position=11,
device_type=SimpleNamespace(u_height=1),
netbox_utilities_rack_placement=SimpleNamespace(width=2, horizontal_position=2),
),
SimpleNamespace(
pk=32,
rack_id=3,
face="front",
position=11,
device_type=SimpleNamespace(u_height=1),
netbox_utilities_rack_placement=None,
),
]
self.assertEqual(
effective_width_positions(devices),
{
31: (2, 2, "stored"),
32: (2, 1, "inferred"),
},
)
def test_adds_rack_width_fields_to_existing_location_fieldset(self):
from utilities.forms.rendering import FieldSet
original = (FieldSet("site", "rack", "position", "latitude", name="Location"),)
extended = include_rack_width_in_fieldsets(original)
self.assertEqual(
extended[0].items,
(
"site",
"rack",
"position",
"utilities_rack_width",
"utilities_horizontal_position",
"latitude",
),
)
self.assertEqual(original[0].items, ("site", "rack", "position", "latitude"))
def test_appends_visible_fallback_fieldset_when_position_is_missing(self):
from utilities.forms.rendering import FieldSet
extended = include_rack_width_in_fieldsets((FieldSet("name", name="Device"),))
self.assertEqual(extended[-1].name, "Rackbreite im Rack")
self.assertEqual(extended[-1].items, ("utilities_rack_width", "utilities_horizontal_position"))
def test_device_edit_view_uses_explicit_rack_width_form(self):
from netbox.registry import registry
for action in ("add", "edit"):
device_view = next(view["view"] for view in registry["views"]["dcim"]["device"] if view["name"] == action)
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
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},
),
):
url = reverse("dcim:device_edit", kwargs={"pk": 1})
view_class = resolve(url).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),
)
rack_position = 'name="position"'
width = 'name="utilities_rack_width"'
horizontal_position = 'name="utilities_horizontal_position"'
latitude = 'name="latitude"'
for html in rendered_pages:
self.assertIn(width, html)
self.assertIn(horizontal_position, html)
self.assertLess(html.index(rack_position), html.index(width))
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
edit_view = next(view["view"] for view in registry["views"]["dcim"]["device"] if view["name"] == "edit")
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},
),
):
form = edit_view.form(data={})
# NetBox 4.6.7's CheckLastUpdatedMixin.clean() updates
# self.cleaned_data but deliberately returns None.
with patch.object(form, "_post_clean"):
self.assertFalse(form.is_valid())
self.assertIsInstance(form.cleaned_data, dict)
@staticmethod
def _anonymous_request():
request = RequestFactory().get("/dcim/devices/1/edit/")
request.user = AnonymousUser()
return request
def test_normalizes_full_width_to_first_position(self):
self.assertEqual(normalize_width_position(1, 4), (1, 1))
def test_rejects_position_outside_selected_width(self):
with self.assertRaises(ValidationError):
normalize_width_position(2, 3)
def test_adjacent_half_widths_do_not_overlap(self):
self.assertFalse(intervals_overlap(horizontal_interval(2, 1), horizontal_interval(2, 2)))
def test_half_width_overlaps_full_width(self):
self.assertTrue(intervals_overlap(horizontal_interval(1, 1), horizontal_interval(2, 2)))
def test_two_halves_cover_full_width(self):
self.assertTrue(intervals_cover_full_width([horizontal_interval(2, 1), horizontal_interval(2, 2)]))
def test_single_half_does_not_cover_full_width(self):
self.assertFalse(intervals_cover_full_width([horizontal_interval(2, 1)]))
def test_same_unit_adjacent_devices_do_not_collide(self):
self.assertFalse(placement_rectangles_overlap(10, 1, 2, 1, 10, 1, 2, 2))
def test_multi_unit_device_collides_only_on_same_horizontal_area(self):
self.assertTrue(placement_rectangles_overlap(10, 2, 2, 1, 11, 1, 2, 1))
self.assertFalse(placement_rectangles_overlap(10, 2, 2, 1, 11, 1, 2, 2))
def test_stages_width_without_requiring_an_already_assigned_rack(self):
device = SimpleNamespace()
stage_width_position(device, 2, 2)
self.assertEqual(device._netbox_utilities_rack_width, 2)
self.assertEqual(device._netbox_utilities_horizontal_position, 2)
@patch("netbox_utilities.rack_width.find_placement_conflict", return_value=None)
def test_current_position_is_available_without_conflict(self, _find_conflict):
rack = SimpleNamespace(units=[1, 1.5, 2, 2.5])
device = SimpleNamespace(
position=1,
device_type=SimpleNamespace(u_height=1),
)
self.assertEqual(available_units_for_device(rack, device), [1])
@patch("netbox_utilities.rack_width.DeviceRackPlacement.objects")
def test_unracked_device_removes_stale_width(self, placement_objects):
device = SimpleNamespace(pk=12, rack_id=None, position=None)
_cleanup_unracked_placement(None, device, using="default")
placement_objects.using.assert_called_once_with("default")
placement_objects.using.return_value.filter.assert_called_once_with(device_id=12)
placement_objects.using.return_value.filter.return_value.delete.assert_called_once_with()
class RackWidthMigrationTest(SimpleTestCase):
def test_postgresql_migration_drops_core_uniqueness(self):
migration = import_module("netbox_utilities.migrations.0007_devicerackplacement")
schema_editor = SimpleNamespace(
connection=SimpleNamespace(vendor="postgresql"),
execute=MagicMock(),
)
migration.drop_core_rack_position_constraint(None, schema_editor)
statement = schema_editor.execute.call_args.args[0]
self.assertIn("DROP CONSTRAINT IF EXISTS", statement)
self.assertIn("dcim_device_unique_rack_position_face", statement)
+546 -12
View File
@@ -1,16 +1,24 @@
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.template.loader import get_template, render_to_string
from django.test import RequestFactory, SimpleTestCase
from django.urls import resolve, reverse
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,
width_aware_reorder_get,
)
@@ -32,14 +40,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,12 +90,30 @@ 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")
first = MagicMock(
pk=10,
rack_id=5,
position=Decimal(1),
face="front",
device_type=SimpleNamespace(u_height=1),
)
second = MagicMock(
pk=11,
rack_id=5,
position=Decimal(2),
face="front",
device_type=SimpleNamespace(u_height=1),
)
first.snapshot.side_effect = lambda: events.append("snapshot-10")
second.snapshot.side_effect = lambda: events.append("snapshot-11")
first.full_clean.side_effect = lambda: events.append("validate-10")
@@ -70,6 +123,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 +137,19 @@ 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):
device = MagicMock(pk=10, position=Decimal(1), face="front")
def test_rejects_changed_device_without_permission(self, device_model, _get_permission, placement_model):
device = MagicMock(
pk=10,
rack_id=5,
position=Decimal(1),
face="front",
device_type=SimpleNamespace(u_height=1),
)
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,27 +160,499 @@ 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,
rack_id=5,
position=Decimal(4),
face="front",
device_type=SimpleNamespace(u_height=1),
)
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,
rack_id=5,
position=Decimal(4),
face="front",
device_type=SimpleNamespace(u_height=1),
)
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},
)
@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_persists_inferred_half_width_when_shared_device_is_moved(
self,
device_model,
_get_permission,
placement_model,
):
left = MagicMock(
pk=10,
rack_id=5,
position=Decimal(4),
face="front",
device_type=SimpleNamespace(u_height=1),
)
right = MagicMock(
pk=11,
rack_id=5,
position=Decimal(4),
face="front",
device_type=SimpleNamespace(u_height=1),
)
device_model.objects.select_for_update.return_value.filter.return_value = [left, right]
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, "x": 0, "y": 4},
{"id": 11, "x": 6, "y": 5},
]
),
grid_columns=12,
)
changed = apply_rack_layout(rack=SimpleNamespace(pk=5), placements=placements, user=user)
self.assertEqual(changed, [right])
placement_model.objects.update_or_create.assert_called_once_with(
device=right,
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.assertIn("if (face !== 'other')", script)
self.assertIn("data.schema_version !== 3", script)
self.assertIn("data.complete !== true", script)
self.assertIn("nativeMountedItems.length", script)
self.assertIn("const nativeItemsByFace = {}", script)
self.assertIn("const device = devicesById.get", script)
self.assertIn("if (!nativeItemsByFace[face].has", script)
self.assertNotIn("partialDeviceIds", script)
self.assertNotIn("JSON.stringify", script)
def test_server_template_uses_complete_twelve_column_device_payload(self):
templates = Path(__file__).parents[1] / "templates" / "netbox_utilities"
page = (templates / "reorder_rack.html").read_text(encoding="utf-8")
grid = (templates / "reorder_rack_grid.html").read_text(encoding="utf-8")
self.assertIn('include "netbox_utilities/reorder_rack_grid.html"', page)
self.assertIn('gs-column="12"', grid)
self.assertIn("{% for device in reorder_devices %}", grid)
self.assertIn('gs-w="{{ device.grid_width }}"', grid)
self.assertIn('gs-x="{{ device.grid_x }}"', grid)
self.assertIn("netbox-utilities-reorder-grid", grid)
self.assertIn('json_script:"netbox-utilities-reorder-rack-width-data"', page)
self.assertIn("netbox_utilities/reorder-rack-width.js", page)
get_template("netbox_utilities/reorder_rack.html")
rendered_grid = render_to_string(
"netbox_utilities/reorder_rack_grid.html",
{
"object": SimpleNamespace(units=[1], u_height=42),
"face": "front",
"grid_id": 0,
"reorder_devices": [
{
"id": 10,
"label": "LEO-Fritzbox",
"face": "front",
"full_depth": False,
"grid_x": 0,
"grid_y": 62,
"grid_width": 6,
"grid_height": 2,
"width": 2,
"horizontal_position": 1,
"color": "f0a000",
"text_color": "000000",
"locked": False,
}
],
},
)
self.assertIn('gs-column="12"', rendered_grid)
self.assertIn('gs-id="10"', rendered_grid)
self.assertIn("LEO-Fritzbox", rendered_grid)
stylesheet = (Path(__file__).parents[1] / "static" / "netbox_utilities" / "netbox_utilities.css").read_text(
encoding="utf-8"
)
self.assertIn('.netbox-utilities-reorder-grid[gs-column="12"]', stylesheet)
self.assertIn('.grid-stack-item[gs-w="6"] { width: 50%; }', stylesheet)
self.assertIn('.grid-stack-item[gs-w="12"] { width: 100%; }', stylesheet)
self.assertIn('.grid-stack-item[gs-x="6"] { left: 50%; }', stylesheet)
self.assertNotIn('.grid-stack-item[gs-w="6"] { width: 50% !important; }', stylesheet)
rendered_image_grid = render_to_string(
"netbox_utilities/reorder_rack_grid.html",
{
"object": SimpleNamespace(units=[1], u_height=42),
"face": "front",
"grid_id": 0,
"images": True,
"labels": False,
"unit_width": 220,
"reorder_devices": [
{
"id": 10,
"label": "LEO-Fritzbox",
"face": "front",
"full_depth": False,
"grid_x": 0,
"grid_y": 62,
"grid_width": 6,
"grid_height": 2,
"width": 2,
"horizontal_position": 1,
"color": "f0a000",
"text_color": "000000",
"locked": False,
"front_image": "/media/device-type-images/fritzbox.png",
"rear_image": None,
}
],
},
)
self.assertIn("background-image: url('/media/device-type-images/fritzbox.png')", rendered_image_grid)
self.assertIn("background-size: 220px", rendered_image_grid)
self.assertNotIn(">LEO-Fritzbox<", rendered_image_grid.replace("\n", ""))
def test_resolved_reorder_page_uses_server_side_width_view(self):
view_class = resolve(reverse("dcim:rack_reorder", kwargs={"pk": 3})).func.view_class
self.assertIs(view_class.get, width_aware_reorder_get)
self.assertEqual(view_class.get.__module__, "netbox_utilities.reorder_rack")
@patch("netbox_utilities.reorder_rack.get_reorder_rack_width_data")
@patch("netbox_utilities.reorder_rack.get_object_or_404")
@patch("netbox_utilities.reorder_rack.Device")
@patch("django.shortcuts.render")
def test_server_side_reorder_view_renders_payload_devices(
self,
render_mock,
device_model,
get_object,
get_width_data,
):
from dcim.models import Rack, Site
rack = Rack(
pk=3,
name="Rack02 - Rechts",
site=Site(pk=1, name="AWO-MWT Leonardo Schule", slug="leonardo"),
u_height=42,
)
get_object.return_value = rack
device_model.objects.filter.return_value.exclude.return_value.select_related.return_value = []
get_width_data.return_value = {
"complete": True,
"unit_width": 220,
"devices": [
{
"id": 10,
"label": "LEO-Fritzbox",
"face": "front",
"full_depth": False,
"grid_x": 0,
"grid_y": 62,
"grid_width": 6,
"grid_height": 2,
"width": 2,
"horizontal_position": 1,
"color": "f0a000",
"text_color": "000000",
"locked": False,
}
],
}
request = RequestFactory().get("/dcim/racks/3/reorder/")
request.user = MagicMock()
request.user.is_authenticated = True
expected_response = SimpleNamespace(status_code=200)
render_mock.return_value = expected_response
response = width_aware_reorder_get(SimpleNamespace(), request, 3)
self.assertIs(response, expected_response)
template_name = render_mock.call_args.args[1]
context = render_mock.call_args.args[2]
self.assertEqual(template_name, "netbox_utilities/reorder_rack.html")
self.assertEqual(context["reorder_devices"][0]["label"], "LEO-Fritzbox")
self.assertEqual(context["reorder_devices"][0]["grid_width"], 6)
self.assertEqual(context["asset_version"], "0.10.3")
self.assertIs(context["reorder_rack_width_data"], get_width_data.return_value)
get_width_data.assert_called_once()
self.assertIs(get_width_data.call_args.kwargs["rack"], rack)
self.assertIsNotNone(get_width_data.call_args.kwargs["devices"])
@patch("netbox_utilities.reorder_rack.get_permission_for_model", return_value="dcim.change_device")
def test_view_owned_rack_data_does_not_repeat_route_or_object_filtering(self, _get_permission):
request = RequestFactory().get("/dcim/racks/3/reorder/")
request.user = MagicMock()
request.user.has_perm.return_value = True
rack = SimpleNamespace(pk=3, u_height=42, desc_units=False)
device = SimpleNamespace(
pk=334,
rack_id=3,
label="LEO-Fritzbox",
device_type=SimpleNamespace(
u_height=1,
is_full_depth=False,
front_image=None,
rear_image=None,
),
role=SimpleNamespace(color="f0a000"),
face="front",
position=Decimal(11),
netbox_utilities_rack_placement=SimpleNamespace(width=2, horizontal_position=1),
)
with (
patch("netbox_utilities.reorder_rack.reorder_rack_width_enabled") as enabled,
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, rack=rack, devices=[device])
enabled.assert_not_called()
self.assertTrue(result["complete"])
self.assertEqual(result["devices"][0]["id"], 334)
self.assertEqual(result["devices"][0]["grid_width"], 6)
@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")):
result = get_reorder_rack_width_data(request)
self.assertEqual(result["status"], "native-fallback")
self.assertEqual(result["devices"], [])
self.assertFalse(result["complete"])
@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_every_mounted_device_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,
rack_id=5,
name="Fritzbox",
label="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,
)
adjacent_device = SimpleNamespace(
pk=12,
rack_id=5,
name="Router",
label="Router",
virtual_chassis=None,
device_type=SimpleNamespace(
u_height=1,
is_full_depth=False,
front_image=None,
rear_image=None,
),
role=SimpleNamespace(color="fedcba"),
face="front",
position=Decimal(4),
netbox_utilities_rack_placement=SimpleNamespace(width=2, horizontal_position=1),
)
full_width_device = SimpleNamespace(
pk=11,
rack_id=5,
name="Switch",
label="Switch",
virtual_chassis=None,
device_type=SimpleNamespace(
u_height=1,
is_full_depth=False,
front_image=None,
rear_image=None,
),
role=SimpleNamespace(color="123456"),
face="front",
position=Decimal(5),
netbox_utilities_rack_placement=None,
)
device_queryset = MagicMock()
device_queryset.filter.return_value.select_related.return_value.order_by.return_value = [
device,
adjacent_device,
full_width_device,
]
device_model.objects.restrict.return_value = device_queryset
with (
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.assertEqual(result["status"], "ready")
self.assertEqual(result["schema_version"], 3)
self.assertTrue(result["complete"])
self.assertEqual(len(result["devices"]), 3)
self.assertEqual(result["devices"][0]["label"], "Fritzbox")
self.assertFalse(result["images"])
self.assertTrue(result["labels"])
device_queryset.filter.assert_called_once_with(
rack=rack,
position__gt=0,
device_type__u_height__gt=0,
)
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},
)
self.assertEqual(
{key: result["devices"][1][key] for key in ("id", "grid_x", "grid_width", "width")},
{"id": 12, "grid_x": 0, "grid_width": 6, "width": 2},
)
self.assertEqual(
{key: result["devices"][2][key] for key in ("id", "grid_x", "grid_width", "width")},
{"id": 11, "grid_x": 0, "grid_width": 12, "width": 1},
)
self.assertEqual(
[device["width_source"] for device in result["devices"]],
["stored", "stored", "default"],
)
class ReorderRackPatchTest(SimpleTestCase):
def test_patches_supported_plugin_only_once(self):
original_update = object()
def original_get(self, request, pk):
return self, request, pk
class SaveViewSet:
update = original_update
views = SimpleNamespace(SaveViewSet=SaveViewSet)
class ReorderView:
get = original_get
api_views = SimpleNamespace(SaveViewSet=SaveViewSet)
page_views = SimpleNamespace(ReorderView=ReorderView)
plugin_config = SimpleNamespace(version="1.1.4")
with (
patch("netbox_utilities.reorder_rack.apps.is_installed", return_value=True),
patch("netbox_utilities.reorder_rack.apps.get_app_config", return_value=plugin_config),
patch("netbox_utilities.reorder_rack.import_module", return_value=views),
patch(
"netbox_utilities.reorder_rack.import_module",
side_effect=lambda name: api_views if name.endswith("api.views") else page_views,
),
):
self.assertTrue(install_reorder_rack_bulk_save())
self.assertTrue(install_reorder_rack_bulk_save())
self.assertIs(SaveViewSet.update, bulk_reorder_update)
self.assertIs(SaveViewSet._netbox_utilities_original_update, original_update)
self.assertIs(ReorderView.get, width_aware_reorder_get)
self.assertIs(ReorderView._netbox_utilities_original_get, original_get)
def test_skips_unsupported_plugin_version(self):
with (
@@ -50,6 +50,17 @@ class FakeQuerySet:
class TenantAutofillTest(SimpleTestCase):
def test_netbox_vlan_group_reverse_scopes_do_not_break_autofill(self):
from ipam.models import VLANGroup
self.assertIsNone(tenant_id_from_object(VLANGroup()))
def test_ignores_reverse_relation_managers_on_scoped_objects(self):
reverse_manager = SimpleNamespace()
vlan_group = FakeObject(site=reverse_manager, rack=reverse_manager)
self.assertIsNone(tenant_id_from_object(vlan_group))
def test_finds_tenant_through_parent_relation(self):
rack = FakeObject(tenant_id=42)
device = FakeObject(rack=rack)
@@ -0,0 +1,285 @@
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from django.http import HttpResponse
from django.test import RequestFactory, SimpleTestCase
from netbox_utilities.topology_views import (
_apply_topology_widths_to_device_tags,
_inject_topology_widths,
_topology_data_for_visible_devices,
_visible_topology_device_urls,
apply_topology_rack_widths,
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(pk=17, 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,
"width_source": "stored",
},
)
@patch("netbox_utilities.topology_views.topology_rack_width_enabled", return_value=True)
def test_queries_all_permitted_devices_and_infers_shared_unit_widths(self, _enabled):
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()
selected_devices = MagicMock()
device_queryset.filter.return_value = selected_devices
device_model = MagicMock()
device_model.objects.restrict.return_value = device_queryset
devices = [
SimpleNamespace(
pk=17,
rack_id=3,
face="front",
position=11,
device_type=SimpleNamespace(u_height=1),
netbox_utilities_rack_placement=None,
get_absolute_url=lambda: "/dcim/devices/17/",
),
SimpleNamespace(
pk=18,
rack_id=3,
face="front",
position=11,
device_type=SimpleNamespace(u_height=1),
netbox_utilities_rack_placement=None,
get_absolute_url=lambda: "/dcim/devices/18/",
),
]
selected_devices.select_related.return_value.order_by.return_value = devices
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__gt=0,
device_type__u_height__gt=0,
)
self.assertEqual(result["status"], "ready")
self.assertEqual(result["schema_version"], 3)
self.assertTrue(result["complete"])
self.assertEqual(len(result["devices"]), 2)
self.assertEqual(result["devices"][0]["width"], 2)
self.assertEqual(result["devices"][0]["horizontal_position"], 1)
self.assertEqual(result["devices"][1]["horizontal_position"], 2)
self.assertEqual(result["devices"][0]["width_source"], "inferred")
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.assertIn("payload?.devices", script)
self.assertIn("payload.status !== 'ready'", script)
self.assertIn("payload?.schema_version !== 3", script)
self.assertIn("placement.source", 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")):
result = get_topology_rack_width_data(self.request)
self.assertEqual(result["status"], "native-fallback")
self.assertEqual(result["devices"], [])
self.assertFalse(result["complete"])
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)
def test_extracts_only_devices_rendered_by_the_authorized_topology_view(self):
html = """
<a class="rack-device" href="/dcim/devices/334/">Fritzbox</a>
<a class="rack-device active" href="/dcim/devices/340/">Grandstream</a>
<a href="/dcim/devices/999/">Not a rack device</a>
"""
self.assertEqual(
_visible_topology_device_urls(html),
{334: "/dcim/devices/334/", 340: "/dcim/devices/340/"},
)
@patch("dcim.models.Device")
def test_builds_both_stored_half_widths_from_visible_he11_devices(self, device_model):
devices = [
SimpleNamespace(
pk=334,
rack_id=3,
face="front",
position=11,
device_type=SimpleNamespace(u_height=1),
netbox_utilities_rack_placement=SimpleNamespace(width=2, horizontal_position=1),
get_absolute_url=lambda: "/dcim/devices/334/",
),
SimpleNamespace(
pk=340,
rack_id=3,
face="front",
position=11,
device_type=SimpleNamespace(u_height=1),
netbox_utilities_rack_placement=SimpleNamespace(width=2, horizontal_position=2),
get_absolute_url=lambda: "/dcim/devices/340/",
),
]
device_model.objects.filter.return_value.select_related.return_value.order_by.return_value = devices
result = _topology_data_for_visible_devices({334: "/dcim/devices/334/", 340: "/dcim/devices/340/"})
self.assertTrue(result["complete"])
self.assertEqual(
[(item["device_id"], item["width"], item["horizontal_position"]) for item in result["devices"]],
[(334, 2, 1), (340, 2, 2)],
)
@patch("netbox_utilities.topology_views._topology_data_for_visible_devices")
def test_injects_stored_widths_into_the_completed_foreign_response(self, get_data):
get_data.return_value = {
"devices": [
{
"device_id": 334,
"url": "/dcim/devices/334/",
"width": 2,
"horizontal_position": 1,
"width_source": "stored",
"left_percent": 0,
"width_percent": 50,
},
{
"device_id": 340,
"url": "/dcim/devices/340/",
"width": 2,
"horizontal_position": 2,
"width_source": "stored",
"left_percent": 50,
"width_percent": 50,
},
],
"status": "ready",
"schema_version": 3,
"complete": True,
}
response = HttpResponse(
'<html><head></head><body><a class="rack-device" href="/dcim/devices/334/">A</a>'
'<a class="rack-device" href="/dcim/devices/340/">B</a></body></html>'
)
result = _inject_topology_widths(response)
html = result.content.decode()
get_data.assert_called_once_with({334: "/dcim/devices/334/", 340: "/dcim/devices/340/"})
self.assertIn('id="netbox-utilities-topology-rack-width-styles"', html)
self.assertIn('.rack-device[href="/dcim/devices/334/"]', html)
self.assertIn("left: calc(50% + 3px) !important", html)
self.assertIn("netbox_utilities/topology-rack-width.js?v=0.10.3", html)
self.assertIn("left:calc(0% + 3px)!important", html)
self.assertIn("left:calc(50% + 3px)!important", html)
self.assertIn("width:calc(50% - 6px)!important", html)
def test_preserves_existing_topology_style_while_applying_width(self):
data = {
"devices": [
{
"url": "/dcim/devices/334/",
"left_percent": 0,
"width_percent": 50,
}
]
}
html = (
'<a class="rack-device" href="/dcim/devices/334/" '
'style="bottom: 10%; height: 2%; border-color: #f00">Fritzbox</a>'
)
result = _apply_topology_widths_to_device_tags(html, data)
self.assertIn("bottom: 10%", result)
self.assertIn("height: 2%", result)
self.assertIn("left:calc(0% + 3px)!important", result)
self.assertIn("width:calc(50% - 6px)!important", result)
@patch("netbox_utilities.topology_views._inject_topology_widths")
@patch("netbox_utilities.topology_views.topology_rack_width_enabled", return_value=True)
def test_response_processor_runs_after_the_topology_view(self, _enabled, inject):
response = HttpResponse("<html></html>")
inject.return_value = response
result = apply_topology_rack_widths(self.request, response)
self.assertIs(result, response)
inject.assert_called_once_with(response)
+244
View File
@@ -0,0 +1,244 @@
import logging
import re
from html import unescape
from django.apps import apps
from django.core.exceptions import ObjectDoesNotExist
from django.db import OperationalError, ProgrammingError
from django.templatetags.static import static
from django.utils.html import escape, json_script
from netbox.plugins import get_plugin_config
from . import __version__
from .rack_width import effective_width_positions
logger = logging.getLogger(__name__)
TOPOLOGY_RACK_ELEVATION_VIEW = "plugins:netbox_topology_views:rack_elevation"
RACK_DEVICE_TAG_RE = re.compile(
r"<a\b[^>]*\bclass=[\"'][^\"']*\brack-device\b[^\"']*[\"'][^>]*>",
re.IGNORECASE,
)
HREF_RE = re.compile(r"\bhref=[\"'](?P<url>[^\"']+)[\"']", re.IGNORECASE)
STYLE_RE = re.compile(r"\bstyle=(?P<quote>[\"'])(?P<style>.*?)(?P=quote)", re.IGNORECASE)
DEVICE_ID_RE = re.compile(r"(?:^|/)dcim/devices/(?P<device_id>\d+)/?(?:$|[?#])")
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):
return serialize_topology_width(
placement.device,
placement.width,
placement.horizontal_position,
source="stored",
)
def serialize_topology_width(device, width, horizontal_position, *, source):
width = int(width)
horizontal_position = int(horizontal_position)
return {
"device_id": device.pk,
"url": 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),
"width_source": source,
}
def get_topology_rack_width_data(request):
"""Return permitted partial-width devices shown by Topology Views."""
if not topology_rack_width_enabled(request):
return None
data = {"devices": [], "status": "ready", "schema_version": 3, "complete": False}
if not request.GET:
return data
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 = list(
Device.objects.restrict(request.user, "view")
.filter(
rack_id__in=racks.values("pk"),
position__gt=0,
device_type__u_height__gt=0,
)
.select_related("device_type", "netbox_utilities_rack_placement")
.order_by("rack_id", "face", "position", "pk")
)
widths = effective_width_positions(devices)
data["devices"] = [
serialize_topology_width(device, width, position, source=source)
for device in devices
for width, position, source in (widths[device.pk],)
if width > 1
]
data["complete"] = True
return data
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 path %s; using its native rack layout",
request.get_full_path(),
exc_info=True,
)
data["status"] = "native-fallback"
return data
def _visible_topology_device_urls(html):
"""Return device URLs which the authorized Topology response actually contains."""
urls = {}
for tag_match in RACK_DEVICE_TAG_RE.finditer(html):
href_match = HREF_RE.search(tag_match.group(0))
if href_match is None:
continue
url = unescape(href_match.group("url"))
device_match = DEVICE_ID_RE.search(url)
if device_match is not None:
urls[int(device_match.group("device_id"))] = url
return urls
def _topology_data_for_visible_devices(visible_urls):
"""Build widths only for devices already exposed by the foreign view."""
from dcim.models import Device
devices = list(
Device.objects.filter(pk__in=visible_urls)
.select_related("device_type", "netbox_utilities_rack_placement")
.order_by("rack_id", "face", "position", "pk")
)
widths = effective_width_positions(devices)
result = {"devices": [], "status": "ready", "schema_version": 3, "complete": True}
for device in devices:
width, position, source = widths[device.pk]
if width <= 1:
continue
descriptor = serialize_topology_width(device, width, position, source=source)
descriptor["url"] = visible_urls[device.pk]
result["devices"].append(descriptor)
return result
def _css_string(value):
return str(value).replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\a ")
def _topology_width_head(data):
rules = []
for device in data["devices"]:
rules.append(
f'.rack-device[href="{_css_string(device["url"])}"] {{'
" right: auto !important;"
f" left: calc({device['left_percent']}% + 3px) !important;"
f" width: calc({device['width_percent']}% - 6px) !important;"
" }"
)
stylesheet = f'<style id="netbox-utilities-topology-rack-width-styles">{"".join(rules)}</style>'
payload = json_script(data, "netbox-utilities-topology-rack-width-data")
script_url = escape(f"{static('netbox_utilities/topology-rack-width.js')}?v={__version__}")
return f'{stylesheet}{payload}<script src="{script_url}" defer></script>'
def _apply_topology_widths_to_device_tags(html, data):
descriptors = {device["url"]: device for device in data["devices"]}
def replace_tag(match):
tag = match.group(0)
href_match = HREF_RE.search(tag)
if href_match is None:
return tag
descriptor = descriptors.get(unescape(href_match.group("url")))
if descriptor is None:
return tag
geometry = (
"right:auto!important;"
f"left:calc({descriptor['left_percent']}% + 3px)!important;"
f"width:calc({descriptor['width_percent']}% - 6px)!important"
)
style_match = STYLE_RE.search(tag)
if style_match is not None:
quote = style_match.group("quote")
existing = style_match.group("style").rstrip().rstrip(";")
replacement = f"style={quote}{existing};{geometry}{quote}"
return f"{tag[: style_match.start()]}{replacement}{tag[style_match.end() :]}"
return f'{tag[:-1]} style="{geometry}">'
return RACK_DEVICE_TAG_RE.sub(replace_tag, html)
def _inject_topology_widths(response):
if (
getattr(response, "streaming", False)
or getattr(response, "status_code", None) != 200
or "text/html" not in response.get("Content-Type", "")
):
return response
html = response.content.decode(response.charset or "utf-8")
if "</head>" not in html.lower() or "netbox-utilities-topology-rack-width-data" in html:
return response
visible_urls = _visible_topology_device_urls(html)
data = _topology_data_for_visible_devices(visible_urls)
html = _apply_topology_widths_to_device_tags(html, data)
insertion = _topology_width_head(data)
head_end = html.lower().index("</head>")
html = f"{html[:head_end]}{insertion}{html[head_end:]}"
content = html.encode(response.charset or "utf-8")
response.content = content
if response.has_header("Content-Length"):
response["Content-Length"] = str(len(content))
return response
def apply_topology_rack_widths(request, response):
"""Apply widths after the optional view has rendered its authorized devices."""
if not topology_rack_width_enabled(request):
return response
try:
return _inject_topology_widths(response)
except Exception:
logger.warning(
"Could not inject partial rack widths into NetBox Topology Views path %s",
request.get_full_path(),
exc_info=True,
)
return response
+2 -2
View File
@@ -4,8 +4,8 @@ build-backend = "hatchling.build"
[project]
name = "netbox-utilities"
version = "0.7.2"
description = "Navigation, tenant utilities, patchpanel mapping, bulk uploads, and atomic rack reordering for NetBox 4.6"
version = "0.10.3"
description = "Navigation, tenant utilities, connection VLANs, partial-width racks, and bulk operations for NetBox 4.6"
readme = "README.md"
requires-python = ">=3.12"
license = { text = "MIT" }