Compare commits
25
Commits
267a78d58d
..
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 |
@@ -4,4 +4,5 @@ __pycache__/
|
|||||||
.ruff_cache/
|
.ruff_cache/
|
||||||
build/
|
build/
|
||||||
dist/
|
dist/
|
||||||
|
.testvenv/
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
|
|||||||
@@ -1,23 +1,42 @@
|
|||||||
# NetBox Utilities
|
# 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.
|
- Jeder Benutzer kann die Menüs der linken Navigation verschieben oder ausblenden.
|
||||||
- Ein Dropdown in der Kopfleiste setzt einen sitzungsweiten Filter für einen Mandanten oder eine Mandantengruppe.
|
- Ein Dropdown in der Kopfleiste setzt einen sitzungsweiten Filter für einen Mandanten oder eine Mandantengruppe.
|
||||||
- Optional verpflichtende Mandantenzuordnung für alle mandantenfähigen Objekte.
|
- Optional verpflichtende Mandantenzuordnung für alle mandantenfähigen Objekte.
|
||||||
- Automatische Vorbelegung von Mandant und Mandantengruppe aus dem Objekt- oder Filterkontext.
|
- Automatische Vorbelegung von Mandant und Mandantengruppe aus dem Objekt- oder Filterkontext.
|
||||||
- Automatische 1:1-Verknüpfung von Front- und Rearports auf Geräten mit der Rolle `Patchpanel`.
|
- Automatische 1:1-Verknüpfung von Front- und Rearports auf Geräten mit der Rolle `Patchpanel`.
|
||||||
|
- Geräte mit optionaler Teilbreite können sich dieselbe Höheneinheit teilen.
|
||||||
|
- Die Rack-Ansicht von MrBlakes optionalem Topology-Views-Plugin stellt Teilbreitengeräte korrekt dar.
|
||||||
- Mehrere Bilder in einem Schritt im Bilder-Tab eines Objekts hochladen.
|
- Mehrere Bilder in einem Schritt im Bilder-Tab eines Objekts hochladen.
|
||||||
- Mehrere Module desselben Typs in einem Schritt in freie Modulschächte einbauen.
|
- Mehrere Module desselben Typs in einem Schritt in freie Modulschächte einbauen.
|
||||||
- Optionale Mehrfachspeicherung für verschobene Geräte aus NetBox Reorder Rack.
|
- Optionale Mehrfachspeicherung für verschobene Geräte aus NetBox Reorder Rack.
|
||||||
|
- 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.
|
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
|
## Kompatibilität
|
||||||
|
|
||||||
- NetBox `>=4.6.5,<4.7`
|
- NetBox `>=4.6.5,<4.7`
|
||||||
- Python `>=3.12`
|
- Python `>=3.12`
|
||||||
- optional: NetBox Reorder Rack `1.1.4`
|
- optional: NetBox Reorder Rack `1.1.4`
|
||||||
|
- optional: 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.
|
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
|
Für eine reproduzierbare Produktivinstallation sollte statt `main` ein
|
||||||
Release-Tag oder ein bestimmter Commit verwendet werden:
|
bereits veröffentlichter Release-Tag oder eine bestimmte Commit-ID verwendet
|
||||||
|
werden. `COMMIT-ID` wird dabei durch den gewünschten Stand ersetzt:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
/opt/netbox/venv/bin/pip install --upgrade --force-reinstall \
|
/opt/netbox/venv/bin/pip install --upgrade --force-reinstall \
|
||||||
"git+https://git.mrblake.cc/MrBlake/Netbox-Utilities.git@v0.7.4"
|
"git+https://git.mrblake.cc/MrBlake/Netbox-Utilities.git@COMMIT-ID"
|
||||||
```
|
```
|
||||||
|
|
||||||
Alternativ kann hinter dem `@` die vollständige Commit-ID stehen.
|
Alternativ kann hinter dem `@` die vollständige Commit-ID stehen.
|
||||||
@@ -67,7 +87,9 @@ PLUGINS = [
|
|||||||
PLUGINS_CONFIG = {
|
PLUGINS_CONFIG = {
|
||||||
"netbox_utilities": {
|
"netbox_utilities": {
|
||||||
"navigation_customization_enabled": True,
|
"navigation_customization_enabled": True,
|
||||||
|
"connection_vlans_enabled": True,
|
||||||
"reorder_rack_bulk_save_enabled": True,
|
"reorder_rack_bulk_save_enabled": True,
|
||||||
|
"topology_views_rack_width_enabled": True,
|
||||||
"tenant_filter_enabled": True,
|
"tenant_filter_enabled": True,
|
||||||
"tenant_required": True,
|
"tenant_required": True,
|
||||||
},
|
},
|
||||||
@@ -100,6 +122,88 @@ sudo systemctl restart netbox netbox-rq
|
|||||||
Nach Änderungen an JavaScript oder CSS kann ein Hard-Reload des Browsers mit
|
Nach Änderungen an JavaScript oder CSS kann ein Hard-Reload des Browsers mit
|
||||||
`Strg+F5` erforderlich sein.
|
`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
|
## Aktualisierung
|
||||||
|
|
||||||
Bei einer Installation aus dem `main`-Branch wird das Paket erneut aus Gitea
|
Bei einer Installation aus dem `main`-Branch wird das Paket erneut aus Gitea
|
||||||
@@ -132,6 +236,145 @@ empfohlen.
|
|||||||
|
|
||||||
## Verwendung
|
## 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
|
### Front- und Rearports von Patchpaneln automatisch verknüpfen
|
||||||
|
|
||||||
Geräte, deren NetBox-Geräterolle `Patchpanel` heißt, werden automatisch als
|
Geräte, deren NetBox-Geräterolle `Patchpanel` heißt, werden automatisch als
|
||||||
@@ -158,6 +401,13 @@ Wenn `netbox-reorder-rack` in Version `1.1.4` installiert und aktiviert ist,
|
|||||||
erweitert NetBox Utilities dessen vorhandenen Speichervorgang automatisch.
|
erweitert NetBox Utilities dessen vorhandenen Speichervorgang automatisch.
|
||||||
In der Drag-and-drop-Ansicht können mehrere Geräte verschoben oder miteinander
|
In der Drag-and-drop-Ansicht können mehrere Geräte verschoben oder miteinander
|
||||||
getauscht und anschließend gemeinsam über **Save** gespeichert werden.
|
getauscht und anschließend gemeinsam über **Save** gespeichert werden.
|
||||||
|
Teilbreitengeräte werden dort ebenfalls nebeneinander angezeigt. Das
|
||||||
|
12-spaltige Raster bildet volle, halbe, Drittel- und Viertelbreite exakt ab.
|
||||||
|
Beim horizontalen Verschieben rastet ein Gerät auf einer für seine Breite
|
||||||
|
gültigen Position ein; **Save** speichert Höhe, Rackseite und Breitenposition
|
||||||
|
gemeinsam in derselben Transaktion. Die gewählte Rackbreite selbst wird in der
|
||||||
|
Reorder-Ansicht nicht verändert und weiterhin auf der Geräte-Neu- oder
|
||||||
|
Bearbeitungsseite gepflegt.
|
||||||
|
|
||||||
Die optionale Abhängigkeit wird separat installiert und für spätere NetBox-
|
Die optionale Abhängigkeit wird separat installiert und für spätere NetBox-
|
||||||
Updates in `/opt/netbox/local_requirements.txt` aufgenommen:
|
Updates in `/opt/netbox/local_requirements.txt` aufgenommen:
|
||||||
@@ -177,8 +427,8 @@ PLUGINS = [
|
|||||||
```
|
```
|
||||||
|
|
||||||
NetBox Utilities verändert keine Dateien des Reorder-Plugins. Stattdessen wird
|
NetBox Utilities verändert keine Dateien des Reorder-Plugins. Stattdessen wird
|
||||||
dessen API beim Start kompatibel erweitert. Beim Speichern werden alle
|
dessen Oberfläche und API beim Start kompatibel erweitert. Beim Speichern
|
||||||
betroffenen Geräte und das Rack gesperrt, die alten Positionen gemeinsam
|
werden alle betroffenen Geräte und das Rack gesperrt, die alten Positionen gemeinsam
|
||||||
freigegeben und danach sämtliche Zielpositionen mit der normalen
|
freigegeben und danach sämtliche Zielpositionen mit der normalen
|
||||||
NetBox-Modellvalidierung geprüft. Der Vorgang läuft in einer Transaktion: Bei
|
NetBox-Modellvalidierung geprüft. Der Vorgang läuft in einer Transaktion: Bei
|
||||||
einem Platz-, Validierungs- oder Berechtigungsfehler bleibt das gesamte Rack
|
einem Platz-, Validierungs- oder Berechtigungsfehler bleibt das gesamte Rack
|
||||||
@@ -191,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
|
verändert und erzeugen lediglich einen Hinweis im NetBox-Log. Das
|
||||||
Reorder-Projekt selbst weist derzeit offiziell nur Kompatibilität bis NetBox
|
Reorder-Projekt selbst weist derzeit offiziell nur Kompatibilität bis NetBox
|
||||||
4.5 aus; die Erweiterung in diesem Plugin ist gezielt für die hier unterstützte
|
4.5 aus; die Erweiterung in diesem Plugin ist gezielt für die hier unterstützte
|
||||||
NetBox-Version 4.6.5 umgesetzt.
|
NetBox-Versionen 4.6.5 bis 4.6.7 umgesetzt und getestet.
|
||||||
|
|
||||||
### Mehrere Bilder hochladen
|
### Mehrere Bilder hochladen
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
from netbox.plugins import PluginConfig, get_plugin_config
|
from netbox.plugins import PluginConfig, get_plugin_config
|
||||||
|
|
||||||
__version__ = "0.7.4"
|
__version__ = "0.10.3"
|
||||||
|
|
||||||
|
|
||||||
class NetBoxUtilitiesConfig(PluginConfig):
|
class NetBoxUtilitiesConfig(PluginConfig):
|
||||||
name = "netbox_utilities"
|
name = "netbox_utilities"
|
||||||
verbose_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__
|
version = __version__
|
||||||
author = "LKE"
|
author = "LKE"
|
||||||
base_url = "utilities"
|
base_url = "utilities"
|
||||||
@@ -14,7 +14,9 @@ class NetBoxUtilitiesConfig(PluginConfig):
|
|||||||
max_version = "4.6.99"
|
max_version = "4.6.99"
|
||||||
default_settings = {
|
default_settings = {
|
||||||
"navigation_customization_enabled": True,
|
"navigation_customization_enabled": True,
|
||||||
|
"connection_vlans_enabled": True,
|
||||||
"reorder_rack_bulk_save_enabled": True,
|
"reorder_rack_bulk_save_enabled": True,
|
||||||
|
"topology_views_rack_width_enabled": True,
|
||||||
"tenant_filter_enabled": True,
|
"tenant_filter_enabled": True,
|
||||||
"tenant_required": True,
|
"tenant_required": True,
|
||||||
}
|
}
|
||||||
@@ -25,12 +27,22 @@ class NetBoxUtilitiesConfig(PluginConfig):
|
|||||||
def ready(self):
|
def ready(self):
|
||||||
super().ready()
|
super().ready()
|
||||||
from .patchpanel import install_patchpanel_automation
|
from .patchpanel import install_patchpanel_automation
|
||||||
|
from .rack_width import install_rack_width_support
|
||||||
from .tenant_scope import install_search_filter
|
from .tenant_scope import install_search_filter
|
||||||
from .tenant_validation import install_tenant_validation
|
from .tenant_validation import install_tenant_validation
|
||||||
|
|
||||||
install_search_filter()
|
install_search_filter()
|
||||||
install_tenant_validation()
|
install_tenant_validation()
|
||||||
install_patchpanel_automation()
|
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"):
|
if get_plugin_config("netbox_utilities", "reorder_rack_bulk_save_enabled"):
|
||||||
from .reorder_rack import install_reorder_rack_bulk_save
|
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()
|
||||||
@@ -22,7 +22,10 @@ class GlobalTenantFilterMiddleware:
|
|||||||
try:
|
try:
|
||||||
if scope is not None and request.method in {"GET", "HEAD"}:
|
if scope is not None and request.method in {"GET", "HEAD"}:
|
||||||
self._inject_filter_parameter(request, scope)
|
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:
|
finally:
|
||||||
active_tenant_scope.reset(token)
|
active_tenant_scope.reset(token)
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
def __str__(self):
|
||||||
return "NetBox Utilities settings"
|
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
|
||||||
@@ -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 dcim.models import Device, Rack
|
||||||
from django.apps import apps
|
from django.apps import apps
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ObjectDoesNotExist, ValidationError
|
||||||
from django.db import transaction
|
from django.db import OperationalError, ProgrammingError, transaction
|
||||||
from django.http import Http404
|
from django.http import Http404
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
|
from netbox.plugins import get_plugin_config
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.exceptions import PermissionDenied
|
from rest_framework.exceptions import PermissionDenied
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from utilities.permissions import get_permission_for_model
|
from utilities.permissions import get_permission_for_model
|
||||||
|
|
||||||
|
from . 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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
SUPPORTED_REORDER_RACK_VERSIONS = {"1.1.4"}
|
SUPPORTED_REORDER_RACK_VERSIONS = {"1.1.4"}
|
||||||
PATCH_MARKER = "_netbox_utilities_bulk_save"
|
PATCH_MARKER = "_netbox_utilities_bulk_save"
|
||||||
|
VIEW_PATCH_MARKER = "_netbox_utilities_width_aware_view"
|
||||||
|
GRID_COLUMNS = 12
|
||||||
|
REORDER_RACK_VIEW = "dcim:rack_reorder"
|
||||||
|
|
||||||
|
|
||||||
class RackLayoutError(Exception):
|
class RackLayoutError(Exception):
|
||||||
@@ -29,9 +37,31 @@ class DevicePlacement:
|
|||||||
device_id: int
|
device_id: int
|
||||||
position: Decimal | None
|
position: Decimal | None
|
||||||
face: str
|
face: str
|
||||||
|
grid_x: int | None
|
||||||
|
|
||||||
|
|
||||||
def parse_device_placements(layout):
|
def grid_dimensions(width, horizontal_position):
|
||||||
|
try:
|
||||||
|
width, horizontal_position = normalize_width_position(width, horizontal_position)
|
||||||
|
except ValidationError as error:
|
||||||
|
raise RackLayoutError(str(error)) from error
|
||||||
|
grid_width = GRID_COLUMNS // width
|
||||||
|
return grid_width, (horizontal_position - 1) * grid_width
|
||||||
|
|
||||||
|
|
||||||
|
def horizontal_position_from_grid_x(width, grid_x):
|
||||||
|
if isinstance(grid_x, bool) or not isinstance(grid_x, int):
|
||||||
|
raise RackLayoutError("Die horizontale Rasterposition muss eine ganze Zahl sein.")
|
||||||
|
grid_width, _ = grid_dimensions(width, 1)
|
||||||
|
if grid_x < 0 or grid_x > GRID_COLUMNS - grid_width or grid_x % grid_width:
|
||||||
|
raise RackLayoutError(f"Rasterposition {grid_x} ist für 1/{width} Rackbreite nicht zulässig.")
|
||||||
|
return grid_x // grid_width + 1
|
||||||
|
|
||||||
|
|
||||||
|
def parse_device_placements(layout, *, grid_columns=None):
|
||||||
|
if grid_columns not in {None, GRID_COLUMNS}:
|
||||||
|
raise RackLayoutError(f"Das Rackraster muss {GRID_COLUMNS} Spalten verwenden.")
|
||||||
|
|
||||||
placements = []
|
placements = []
|
||||||
seen_device_ids = set()
|
seen_device_ids = set()
|
||||||
|
|
||||||
@@ -53,6 +83,7 @@ def parse_device_placements(layout):
|
|||||||
|
|
||||||
if section == "other":
|
if section == "other":
|
||||||
position = None
|
position = None
|
||||||
|
grid_x = None
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
position = Decimal(str(item["y"]))
|
position = Decimal(str(item["y"]))
|
||||||
@@ -61,7 +92,13 @@ def parse_device_placements(layout):
|
|||||||
if not position.is_finite():
|
if not position.is_finite():
|
||||||
raise RackLayoutError(f"Gerät {device_id} besitzt keine gültige Rack-Position.")
|
raise RackLayoutError(f"Gerät {device_id} besitzt keine gültige Rack-Position.")
|
||||||
|
|
||||||
placements.append(DevicePlacement(device_id=device_id, position=position, face=face))
|
grid_x = None
|
||||||
|
if grid_columns == GRID_COLUMNS:
|
||||||
|
grid_x = item.get("x")
|
||||||
|
if isinstance(grid_x, bool) or not isinstance(grid_x, int) or not 0 <= grid_x < GRID_COLUMNS:
|
||||||
|
raise RackLayoutError(f"Gerät {device_id} besitzt keine gültige horizontale Rasterposition.")
|
||||||
|
|
||||||
|
placements.append(DevicePlacement(device_id=device_id, position=position, face=face, grid_x=grid_x))
|
||||||
|
|
||||||
return placements
|
return placements
|
||||||
|
|
||||||
@@ -77,35 +114,84 @@ def apply_rack_layout(*, rack, placements, user):
|
|||||||
missing = ", ".join(str(device_id) for device_id in sorted(missing_ids))
|
missing = ", ".join(str(device_id) for device_id in sorted(missing_ids))
|
||||||
raise RackLayoutError(f"Diese Geräte gehören nicht zum gewählten Rack: {missing}.")
|
raise RackLayoutError(f"Diese Geräte gehören nicht zum gewählten Rack: {missing}.")
|
||||||
|
|
||||||
|
stored_placements = {
|
||||||
|
placement.device_id: placement
|
||||||
|
for placement in DeviceRackPlacement.objects.select_for_update().filter(device_id__in=placement_by_id)
|
||||||
|
}
|
||||||
|
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 = [
|
changed = [
|
||||||
(devices[placement.device_id], placement)
|
(device, placement, target_width, target_horizontal_position)
|
||||||
for placement in placements
|
for (
|
||||||
|
device,
|
||||||
|
placement,
|
||||||
|
target_width,
|
||||||
|
target_horizontal_position,
|
||||||
|
current_width,
|
||||||
|
current_horizontal_position,
|
||||||
|
) in target_placements
|
||||||
if (
|
if (
|
||||||
devices[placement.device_id].position != placement.position
|
device.position != placement.position
|
||||||
or devices[placement.device_id].face != placement.face
|
or device.face != placement.face
|
||||||
|
or current_width != target_width
|
||||||
|
or current_horizontal_position != target_horizontal_position
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
if not changed:
|
if not changed:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
permission = get_permission_for_model(Device, "change")
|
permission = get_permission_for_model(Device, "change")
|
||||||
for device, _placement in changed:
|
for device, _placement, _width, _horizontal_position in changed:
|
||||||
if not user.has_perm(permission, obj=device):
|
if not user.has_perm(permission, obj=device):
|
||||||
raise PermissionDenied(f"Keine Berechtigung zum Verschieben von {device}.")
|
raise PermissionDenied(f"Keine Berechtigung zum Verschieben von {device}.")
|
||||||
|
|
||||||
for device, _placement in changed:
|
for device, _placement, _width, _horizontal_position in changed:
|
||||||
device.snapshot()
|
device.snapshot()
|
||||||
|
|
||||||
changed_ids = [device.pk for device, _placement in changed]
|
changed_ids = [device.pk for device, _placement, _width, _horizontal_position in changed]
|
||||||
Device.objects.filter(pk__in=changed_ids).update(position=None, face="")
|
Device.objects.filter(pk__in=changed_ids).update(position=None, face="")
|
||||||
|
|
||||||
for device, placement in changed:
|
for device, placement, width, horizontal_position in changed:
|
||||||
device.position = placement.position
|
device.position = placement.position
|
||||||
device.face = placement.face
|
device.face = placement.face
|
||||||
|
stage_width_position(device, width, horizontal_position)
|
||||||
device.full_clean()
|
device.full_clean()
|
||||||
device.save()
|
device.save()
|
||||||
|
|
||||||
return [device for device, _placement in changed]
|
if width == FULL_WIDTH or placement.position is None:
|
||||||
|
DeviceRackPlacement.objects.filter(device=device).delete()
|
||||||
|
else:
|
||||||
|
DeviceRackPlacement.objects.update_or_create(
|
||||||
|
device=device,
|
||||||
|
defaults={"width": width, "horizontal_position": horizontal_position},
|
||||||
|
)
|
||||||
|
|
||||||
|
return [device for device, _placement, _width, _horizontal_position in changed]
|
||||||
|
|
||||||
|
|
||||||
def _validation_error_message(error):
|
def _validation_error_message(error):
|
||||||
@@ -117,6 +203,149 @@ def _validation_error_message(error):
|
|||||||
return "; ".join(str(message) for message in error.messages)
|
return "; ".join(str(message) for message in error.messages)
|
||||||
|
|
||||||
|
|
||||||
|
def is_reorder_rack_request(request):
|
||||||
|
resolver_match = getattr(request, "resolver_match", None)
|
||||||
|
return getattr(resolver_match, "view_name", None) == REORDER_RACK_VIEW
|
||||||
|
|
||||||
|
|
||||||
|
def reorder_rack_width_enabled(request):
|
||||||
|
if not (
|
||||||
|
is_reorder_rack_request(request)
|
||||||
|
and get_plugin_config("netbox_utilities", "reorder_rack_bulk_save_enabled")
|
||||||
|
and apps.is_installed("netbox_reorder_rack")
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
plugin_config = apps.get_app_config("netbox_reorder_rack")
|
||||||
|
return getattr(plugin_config, "version", None) in SUPPORTED_REORDER_RACK_VERSIONS
|
||||||
|
|
||||||
|
|
||||||
|
def reorder_grid_y(rack, position, height):
|
||||||
|
rack_height = int(Decimal(str(rack.u_height)) * 2)
|
||||||
|
grid_height = int(Decimal(str(height)) * 2)
|
||||||
|
unit_id = int(Decimal(str(position)) * 2)
|
||||||
|
if rack.desc_units:
|
||||||
|
return unit_id - 2
|
||||||
|
if grid_height > 1:
|
||||||
|
return rack_height - unit_id - grid_height + 2
|
||||||
|
return rack_height - unit_id
|
||||||
|
|
||||||
|
|
||||||
|
def _image_url(image):
|
||||||
|
if not image:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
return image.url
|
||||||
|
except (AttributeError, ValueError):
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _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):
|
def bulk_reorder_update(self, request, pk=None):
|
||||||
serializer = self.serializer_class(data=request.data)
|
serializer = self.serializer_class(data=request.data)
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
@@ -128,7 +357,12 @@ def bulk_reorder_update(self, request, pk=None):
|
|||||||
if layout["rack_id"] != rack.pk:
|
if layout["rack_id"] != rack.pk:
|
||||||
raise RackLayoutError("Die Rack-ID der Anfrage stimmt nicht mit dem Ziel-Rack überein.")
|
raise RackLayoutError("Die Rack-ID der Anfrage stimmt nicht mit dem Ziel-Rack überein.")
|
||||||
|
|
||||||
placements = parse_device_placements(layout)
|
grid_columns_header = request.headers.get("X-NetBox-Utilities-Rack-Grid-Columns")
|
||||||
|
try:
|
||||||
|
grid_columns = int(grid_columns_header) if grid_columns_header is not None else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise RackLayoutError("Das angegebene Rackraster ist ungültig.") from None
|
||||||
|
placements = parse_device_placements(layout, grid_columns=grid_columns)
|
||||||
changed_devices = apply_rack_layout(rack=rack, placements=placements, user=request.user)
|
changed_devices = apply_rack_layout(rack=rack, placements=placements, user=request.user)
|
||||||
if not changed_devices:
|
if not changed_devices:
|
||||||
return Response(
|
return Response(
|
||||||
@@ -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():
|
def install_reorder_rack_bulk_save():
|
||||||
if not apps.is_installed("netbox_reorder_rack"):
|
if not apps.is_installed("netbox_reorder_rack"):
|
||||||
return False
|
return False
|
||||||
@@ -182,13 +470,19 @@ def install_reorder_rack_bulk_save():
|
|||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
views = import_module("netbox_reorder_rack.api.views")
|
api_views = import_module("netbox_reorder_rack.api.views")
|
||||||
save_viewset = views.SaveViewSet
|
save_viewset = api_views.SaveViewSet
|
||||||
if getattr(save_viewset, PATCH_MARKER, False):
|
if not getattr(save_viewset, PATCH_MARKER, False):
|
||||||
return True
|
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)
|
logger.info("Enabled atomic multi-device saving for netbox-reorder-rack %s", plugin_version)
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ def navigation_customization_enabled():
|
|||||||
return bool(get_plugin_config("netbox_utilities", "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():
|
def tenant_filter_enabled():
|
||||||
if not get_plugin_config("netbox_utilities", "tenant_filter_enabled"):
|
if not get_plugin_config("netbox_utilities", "tenant_filter_enabled"):
|
||||||
return False
|
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) {
|
function initialize(root = document) {
|
||||||
rewriteImageUploadLinks(root);
|
rewriteImageUploadLinks(root);
|
||||||
configureTenantConfirmation(root);
|
configureTenantConfirmation(root);
|
||||||
configureImagePreview(root);
|
configureImagePreview(root);
|
||||||
|
configureRackWidth(root);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (document.readyState === 'loading') {
|
if (document.readyState === 'loading') {
|
||||||
|
|||||||
@@ -29,6 +29,51 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rack-device.netbox-utilities-partial-width {
|
||||||
|
box-sizing: border-box;
|
||||||
|
right: auto !important;
|
||||||
|
left: calc(var(--netbox-utilities-rack-device-left) + 3px) !important;
|
||||||
|
width: calc(var(--netbox-utilities-rack-device-width) - 6px) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* netbox-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 {
|
.netbox-utilities-sidebar-resizer {
|
||||||
display: none;
|
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 tenancy.models import Tenant, TenantGroup
|
||||||
|
|
||||||
from . import __version__
|
from . import __version__
|
||||||
from .models import NavigationPreference
|
from .models import (
|
||||||
|
CableVLANAssignment,
|
||||||
|
DeviceRackPlacement,
|
||||||
|
NavigationPreference,
|
||||||
|
WirelessLinkVLANAssignment,
|
||||||
|
)
|
||||||
from .navigation_helpers import (
|
from .navigation_helpers import (
|
||||||
SIDEBAR_WIDTH_DEFAULT,
|
SIDEBAR_WIDTH_DEFAULT,
|
||||||
SIDEBAR_WIDTH_MAX,
|
SIDEBAR_WIDTH_MAX,
|
||||||
@@ -14,7 +19,7 @@ from .navigation_helpers import (
|
|||||||
normalize_preferences,
|
normalize_preferences,
|
||||||
normalize_sidebar_width,
|
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):
|
class UtilitiesGlobalContent(PluginTemplateExtension):
|
||||||
@@ -99,5 +104,47 @@ class DeviceUtilitiesContent(PluginTemplateExtension):
|
|||||||
return ""
|
return ""
|
||||||
return self.render("netbox_utilities/device_bulk_module_button.html")
|
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):
|
if isinstance(obj, Tenant):
|
||||||
return obj.pk
|
return obj.pk
|
||||||
|
|
||||||
field_names = {field.name for field in obj._meta.get_fields()}
|
meta = getattr(obj, "_meta", None)
|
||||||
if "tenant" in field_names and getattr(obj, "tenant_id", 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
|
return obj.tenant_id
|
||||||
|
|
||||||
for relation in PARENT_RELATIONS:
|
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
|
continue
|
||||||
try:
|
try:
|
||||||
parent = getattr(obj, relation, None)
|
parent = getattr(obj, relation, None)
|
||||||
except (ObjectDoesNotExist, ValueError):
|
except (AttributeError, ObjectDoesNotExist, ValueError):
|
||||||
continue
|
continue
|
||||||
if tenant_id := tenant_id_from_object(parent, depth + 1):
|
if tenant_id := tenant_id_from_object(parent, depth + 1):
|
||||||
return tenant_id
|
return tenant_id
|
||||||
@@ -76,11 +80,7 @@ def _related_objects(form, field_name):
|
|||||||
|
|
||||||
def _is_cable(instance):
|
def _is_cable(instance):
|
||||||
meta = getattr(instance, "_meta", None)
|
meta = getattr(instance, "_meta", None)
|
||||||
return bool(
|
return bool(meta and getattr(meta, "app_label", None) == "dcim" and getattr(meta, "model_name", None) == "cable")
|
||||||
meta
|
|
||||||
and getattr(meta, "app_label", None) == "dcim"
|
|
||||||
and getattr(meta, "model_name", None) == "cable"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _instance_cable_terminations(instance):
|
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 types import SimpleNamespace
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from django.http import HttpResponse
|
||||||
from django.test import RequestFactory, SimpleTestCase
|
from django.test import RequestFactory, SimpleTestCase
|
||||||
|
|
||||||
from netbox_utilities.middleware import GlobalTenantFilterMiddleware
|
from netbox_utilities.middleware import GlobalTenantFilterMiddleware
|
||||||
@@ -28,6 +29,21 @@ class GlobalTenantFilterMiddlewareTest(SimpleTestCase):
|
|||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.factory = RequestFactory()
|
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")
|
@patch("netbox_utilities.middleware.resolve")
|
||||||
def test_injects_and_overrides_tenant_id(self, mocked_resolve):
|
def test_injects_and_overrides_tenant_id(self, mocked_resolve):
|
||||||
mocked_resolve.return_value = resolver_match(TenantFilterSet)
|
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)
|
||||||
@@ -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 decimal import Decimal
|
||||||
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
from django.test import SimpleTestCase
|
from django.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 rest_framework.exceptions import PermissionDenied
|
||||||
|
|
||||||
from netbox_utilities.reorder_rack import (
|
from netbox_utilities.reorder_rack import (
|
||||||
RackLayoutError,
|
RackLayoutError,
|
||||||
apply_rack_layout,
|
apply_rack_layout,
|
||||||
bulk_reorder_update,
|
bulk_reorder_update,
|
||||||
|
get_reorder_rack_width_data,
|
||||||
|
grid_dimensions,
|
||||||
|
horizontal_position_from_grid_x,
|
||||||
install_reorder_rack_bulk_save,
|
install_reorder_rack_bulk_save,
|
||||||
parse_device_placements,
|
parse_device_placements,
|
||||||
|
reorder_grid_y,
|
||||||
|
width_aware_reorder_get,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -32,14 +40,41 @@ class RackLayoutParsingTest(SimpleTestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
[(item.device_id, item.position, item.face) for item in placements],
|
[(item.device_id, item.position, item.face, item.grid_x) for item in placements],
|
||||||
[
|
[
|
||||||
(10, Decimal(4), "front"),
|
(10, Decimal(4), "front", None),
|
||||||
(11, Decimal("8.5"), "front"),
|
(11, Decimal("8.5"), "front", None),
|
||||||
(12, None, ""),
|
(12, None, "", None),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_reads_horizontal_positions_only_from_marked_twelve_column_payload(self):
|
||||||
|
placements = parse_device_placements(
|
||||||
|
rack_layout(front=[{"id": 10, "x": 6, "y": 4}]),
|
||||||
|
grid_columns=12,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(placements[0].grid_x, 6)
|
||||||
|
|
||||||
|
def test_maps_rack_widths_to_twelve_column_grid(self):
|
||||||
|
self.assertEqual(grid_dimensions(1, 1), (12, 0))
|
||||||
|
self.assertEqual(grid_dimensions(2, 2), (6, 6))
|
||||||
|
self.assertEqual(grid_dimensions(3, 2), (4, 4))
|
||||||
|
self.assertEqual(grid_dimensions(4, 4), (3, 9))
|
||||||
|
|
||||||
|
def test_maps_grid_x_back_to_horizontal_position(self):
|
||||||
|
self.assertEqual(horizontal_position_from_grid_x(2, 6), 2)
|
||||||
|
self.assertEqual(horizontal_position_from_grid_x(3, 8), 3)
|
||||||
|
self.assertEqual(horizontal_position_from_grid_x(4, 9), 4)
|
||||||
|
|
||||||
|
def test_rejects_misaligned_grid_position(self):
|
||||||
|
with self.assertRaisesMessage(RackLayoutError, "nicht zulässig"):
|
||||||
|
horizontal_position_from_grid_x(2, 3)
|
||||||
|
|
||||||
|
def test_rejects_non_integer_grid_position(self):
|
||||||
|
with self.assertRaisesMessage(RackLayoutError, "ganze Zahl"):
|
||||||
|
horizontal_position_from_grid_x(2, 6.0)
|
||||||
|
|
||||||
def test_rejects_duplicate_devices(self):
|
def test_rejects_duplicate_devices(self):
|
||||||
with self.assertRaisesMessage(RackLayoutError, "mehrfach"):
|
with self.assertRaisesMessage(RackLayoutError, "mehrfach"):
|
||||||
parse_device_placements(
|
parse_device_placements(
|
||||||
@@ -55,12 +90,30 @@ class RackLayoutParsingTest(SimpleTestCase):
|
|||||||
|
|
||||||
|
|
||||||
class ApplyRackLayoutTest(SimpleTestCase):
|
class ApplyRackLayoutTest(SimpleTestCase):
|
||||||
|
@patch("netbox_utilities.reorder_rack.DeviceRackPlacement")
|
||||||
@patch("netbox_utilities.reorder_rack.get_permission_for_model", return_value="dcim.change_device")
|
@patch("netbox_utilities.reorder_rack.get_permission_for_model", return_value="dcim.change_device")
|
||||||
@patch("netbox_utilities.reorder_rack.Device")
|
@patch("netbox_utilities.reorder_rack.Device")
|
||||||
def test_clears_all_old_positions_before_saving_targets(self, device_model, _get_permission):
|
def test_clears_all_old_positions_before_saving_targets(
|
||||||
|
self,
|
||||||
|
device_model,
|
||||||
|
_get_permission,
|
||||||
|
placement_model,
|
||||||
|
):
|
||||||
events = []
|
events = []
|
||||||
first = MagicMock(pk=10, position=Decimal(1), face="front")
|
first = MagicMock(
|
||||||
second = MagicMock(pk=11, position=Decimal(2), face="front")
|
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")
|
first.snapshot.side_effect = lambda: events.append("snapshot-10")
|
||||||
second.snapshot.side_effect = lambda: events.append("snapshot-11")
|
second.snapshot.side_effect = lambda: events.append("snapshot-11")
|
||||||
first.full_clean.side_effect = lambda: events.append("validate-10")
|
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.select_for_update.return_value.filter.return_value = [first, second]
|
||||||
device_model.objects.filter.return_value.update.side_effect = lambda **_kwargs: events.append("clear")
|
device_model.objects.filter.return_value.update.side_effect = lambda **_kwargs: events.append("clear")
|
||||||
|
placement_model.objects.select_for_update.return_value.filter.return_value = []
|
||||||
user = MagicMock()
|
user = MagicMock()
|
||||||
user.has_perm.return_value = True
|
user.has_perm.return_value = True
|
||||||
placements = parse_device_placements(rack_layout(front=[{"id": 10, "y": 2}, {"id": 11, "y": 1}]))
|
placements = parse_device_placements(rack_layout(front=[{"id": 10, "y": 2}, {"id": 11, "y": 1}]))
|
||||||
@@ -83,11 +137,19 @@ class ApplyRackLayoutTest(SimpleTestCase):
|
|||||||
self.assertEqual((first.position, first.face), (Decimal(2), "front"))
|
self.assertEqual((first.position, first.face), (Decimal(2), "front"))
|
||||||
self.assertEqual((second.position, second.face), (Decimal(1), "front"))
|
self.assertEqual((second.position, second.face), (Decimal(1), "front"))
|
||||||
|
|
||||||
|
@patch("netbox_utilities.reorder_rack.DeviceRackPlacement")
|
||||||
@patch("netbox_utilities.reorder_rack.get_permission_for_model", return_value="dcim.change_device")
|
@patch("netbox_utilities.reorder_rack.get_permission_for_model", return_value="dcim.change_device")
|
||||||
@patch("netbox_utilities.reorder_rack.Device")
|
@patch("netbox_utilities.reorder_rack.Device")
|
||||||
def test_rejects_changed_device_without_permission(self, device_model, _get_permission):
|
def test_rejects_changed_device_without_permission(self, device_model, _get_permission, placement_model):
|
||||||
device = MagicMock(pk=10, position=Decimal(1), face="front")
|
device = MagicMock(
|
||||||
|
pk=10,
|
||||||
|
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]
|
device_model.objects.select_for_update.return_value.filter.return_value = [device]
|
||||||
|
placement_model.objects.select_for_update.return_value.filter.return_value = []
|
||||||
user = MagicMock()
|
user = MagicMock()
|
||||||
user.has_perm.return_value = False
|
user.has_perm.return_value = False
|
||||||
placements = parse_device_placements(rack_layout(front=[{"id": 10, "y": 2}]))
|
placements = parse_device_placements(rack_layout(front=[{"id": 10, "y": 2}]))
|
||||||
@@ -98,27 +160,499 @@ class ApplyRackLayoutTest(SimpleTestCase):
|
|||||||
device_model.objects.filter.assert_not_called()
|
device_model.objects.filter.assert_not_called()
|
||||||
device.save.assert_not_called()
|
device.save.assert_not_called()
|
||||||
|
|
||||||
|
@patch("netbox_utilities.reorder_rack.DeviceRackPlacement")
|
||||||
|
@patch("netbox_utilities.reorder_rack.get_permission_for_model", return_value="dcim.change_device")
|
||||||
|
@patch("netbox_utilities.reorder_rack.Device")
|
||||||
|
def test_saves_horizontal_only_move_for_partial_width_device(
|
||||||
|
self,
|
||||||
|
device_model,
|
||||||
|
_get_permission,
|
||||||
|
placement_model,
|
||||||
|
):
|
||||||
|
device = MagicMock(
|
||||||
|
pk=10,
|
||||||
|
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):
|
class ReorderRackPatchTest(SimpleTestCase):
|
||||||
def test_patches_supported_plugin_only_once(self):
|
def test_patches_supported_plugin_only_once(self):
|
||||||
original_update = object()
|
original_update = object()
|
||||||
|
|
||||||
|
def original_get(self, request, pk):
|
||||||
|
return self, request, pk
|
||||||
|
|
||||||
class SaveViewSet:
|
class SaveViewSet:
|
||||||
update = original_update
|
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")
|
plugin_config = SimpleNamespace(version="1.1.4")
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch("netbox_utilities.reorder_rack.apps.is_installed", return_value=True),
|
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.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.assertTrue(install_reorder_rack_bulk_save())
|
self.assertTrue(install_reorder_rack_bulk_save())
|
||||||
|
|
||||||
self.assertIs(SaveViewSet.update, bulk_reorder_update)
|
self.assertIs(SaveViewSet.update, bulk_reorder_update)
|
||||||
self.assertIs(SaveViewSet._netbox_utilities_original_update, original_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):
|
def test_skips_unsupported_plugin_version(self):
|
||||||
with (
|
with (
|
||||||
|
|||||||
@@ -50,6 +50,17 @@ class FakeQuerySet:
|
|||||||
|
|
||||||
|
|
||||||
class TenantAutofillTest(SimpleTestCase):
|
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):
|
def test_finds_tenant_through_parent_relation(self):
|
||||||
rack = FakeObject(tenant_id=42)
|
rack = FakeObject(tenant_id=42)
|
||||||
device = FakeObject(rack=rack)
|
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]
|
[project]
|
||||||
name = "netbox-utilities"
|
name = "netbox-utilities"
|
||||||
version = "0.7.4"
|
version = "0.10.3"
|
||||||
description = "Navigation, tenant utilities, patchpanel mapping, bulk uploads, and atomic rack reordering for NetBox 4.6"
|
description = "Navigation, tenant utilities, connection VLANs, partial-width racks, and bulk operations for NetBox 4.6"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
license = { text = "MIT" }
|
license = { text = "MIT" }
|
||||||
|
|||||||
Reference in New Issue
Block a user