Compare commits
27
Commits
8c6c48531e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3d658b52b | ||
|
|
d245ae0f79 | ||
|
|
4421000409 | ||
|
|
bd748ef985 | ||
|
|
1c6708216c | ||
|
|
7216dad2b4 | ||
|
|
639dc958a5 | ||
|
|
839b076d94 | ||
|
|
79a071597c | ||
|
|
e42580d651 | ||
|
|
270d821219 | ||
|
|
33490d40c4 | ||
|
|
a5f8cb62c7 | ||
|
|
bc2024ac13 | ||
|
|
a0358136b2 | ||
|
|
70825c4040 | ||
|
|
ec67e6b7d0 | ||
|
|
4ee78ae4fe | ||
|
|
c663069825 | ||
|
|
7dc9b4d91d | ||
|
|
c38ab30acc | ||
|
|
008a69ed79 | ||
|
|
37fc55449f | ||
|
|
2a7459c460 | ||
|
|
a504341442 | ||
|
|
267a78d58d | ||
|
|
66d13292f2 |
@@ -4,4 +4,5 @@ __pycache__/
|
||||
.ruff_cache/
|
||||
build/
|
||||
dist/
|
||||
.testvenv/
|
||||
*.egg-info/
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
)
|
||||
@@ -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",
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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 => ({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
})[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);
|
||||
})();
|
||||
@@ -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> {% 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>
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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"))))
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user