feat: assign VLANs to network connections
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# NetBox Utilities
|
# NetBox Utilities
|
||||||
|
|
||||||
Plugin für **NetBox 4.6.5 bis 4.6.7** mit elf Funktionen:
|
Plugin für **NetBox 4.6.5 bis 4.6.7** 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.
|
||||||
@@ -13,6 +13,7 @@ Plugin für **NetBox 4.6.5 bis 4.6.7** mit elf Funktionen:
|
|||||||
- 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.
|
- 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.
|
||||||
|
|
||||||
@@ -73,6 +74,7 @@ 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,
|
"topology_views_rack_width_enabled": True,
|
||||||
"tenant_filter_enabled": True,
|
"tenant_filter_enabled": True,
|
||||||
@@ -221,6 +223,34 @@ 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
|
### Mehrere Geräte nebeneinander in derselben HE
|
||||||
|
|
||||||
Auf der normalen Seite zum **Anlegen oder Bearbeiten eines Geräts** stehen
|
Auf der normalen Seite zum **Anlegen oder Bearbeiten eines Geräts** stehen
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
from netbox.plugins import PluginConfig, get_plugin_config
|
from netbox.plugins import PluginConfig, get_plugin_config
|
||||||
|
|
||||||
__version__ = "0.9.11"
|
__version__ = "0.10.0"
|
||||||
|
|
||||||
|
|
||||||
class NetBoxUtilitiesConfig(PluginConfig):
|
class NetBoxUtilitiesConfig(PluginConfig):
|
||||||
name = "netbox_utilities"
|
name = "netbox_utilities"
|
||||||
verbose_name = "NetBox Utilities"
|
verbose_name = "NetBox Utilities"
|
||||||
description = "Navigation, tenant utilities, partial-width rack devices, bulk uploads, and 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,6 +14,7 @@ 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,
|
"topology_views_rack_width_enabled": True,
|
||||||
"tenant_filter_enabled": True,
|
"tenant_filter_enabled": True,
|
||||||
@@ -34,6 +35,10 @@ class NetBoxUtilitiesConfig(PluginConfig):
|
|||||||
install_tenant_validation()
|
install_tenant_validation()
|
||||||
install_patchpanel_automation()
|
install_patchpanel_automation()
|
||||||
install_rack_width_support()
|
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"):
|
if self.apps.is_installed("netbox_export"):
|
||||||
from .netbox_export import install_netbox_export_rack_width_support
|
from .netbox_export import install_netbox_export_rack_width_support
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
from copy import deepcopy
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
|
from ipam.models import VLAN
|
||||||
|
from utilities.forms.fields import DynamicModelMultipleChoiceField
|
||||||
|
from utilities.forms.rendering import FieldSet
|
||||||
|
|
||||||
|
from .models import CableVLANAssignment, WirelessLinkVLANAssignment
|
||||||
|
|
||||||
|
FORM_FIELD = "utilities_vlans"
|
||||||
|
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(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:
|
||||||
|
index = items.index("tags") if "tags" in items else len(items)
|
||||||
|
if FORM_FIELD not in items:
|
||||||
|
items.insert(index, 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(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_vlans = DynamicModelMultipleChoiceField(
|
||||||
|
queryset=VLAN.objects.all(),
|
||||||
|
required=False,
|
||||||
|
selector=True,
|
||||||
|
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:
|
||||||
|
self.initial[FORM_FIELD] = assignment.vlans.all()
|
||||||
|
|
||||||
|
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()
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -79,3 +79,45 @@ class DeviceRackPlacement(models.Model):
|
|||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.device}: 1/{self.width}, Position {self.horizontal_position}"
|
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}"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 DeviceRackPlacement, 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):
|
||||||
@@ -112,4 +117,34 @@ class DeviceUtilitiesContent(PluginTemplateExtension):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
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,12 @@
|
|||||||
|
{% load form_helpers %}
|
||||||
|
|
||||||
|
{% include 'dcim/htmx/cable_edit.html' %}
|
||||||
|
|
||||||
|
{% if form.utilities_vlans %}
|
||||||
|
<div class="field-group mb-5">
|
||||||
|
<div class="row">
|
||||||
|
<h2 class="col-9 offset-3">VLANs der Verbindung</h2>
|
||||||
|
</div>
|
||||||
|
{% render_field form.utilities_vlans %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
@@ -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,115 @@
|
|||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from django import forms
|
||||||
|
from django.forms.utils import ErrorDict
|
||||||
|
from django.test import SimpleTestCase
|
||||||
|
|
||||||
|
from netbox_utilities.connection_vlans import (
|
||||||
|
FORM_FIELD,
|
||||||
|
_build_connection_vlan_form,
|
||||||
|
include_connection_vlans_in_fieldsets,
|
||||||
|
)
|
||||||
|
from netbox_utilities.models import CableVLANAssignment, WirelessLinkVLANAssignment
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectionVLANTest(SimpleTestCase):
|
||||||
|
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", 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(FORM_FIELD, cable_form.base_fields)
|
||||||
|
self.assertEqual(CableEditView.template_name, "netbox_utilities/cable_edit.html")
|
||||||
|
self.assertEqual(CableEditView.htmx_template_name, "netbox_utilities/cable_edit_form.html")
|
||||||
|
self.assertIn(FORM_FIELD, WirelessLinkEditView.form.base_fields)
|
||||||
|
|
||||||
|
@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)
|
||||||
|
vlan_b = SimpleNamespace(pk=20)
|
||||||
|
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])
|
||||||
|
|
||||||
|
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()
|
||||||
@@ -62,6 +62,14 @@ class FakeDevice:
|
|||||||
|
|
||||||
|
|
||||||
class NetBoxExportRackWidthTest(SimpleTestCase):
|
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):
|
def test_export_embeds_partial_and_full_rack_widths_on_devices(self):
|
||||||
partial = FakeDevice(1, width=2, horizontal_position=2)
|
partial = FakeDevice(1, width=2, horizontal_position=2)
|
||||||
full = FakeDevice(2)
|
full = FakeDevice(2)
|
||||||
|
|||||||
@@ -449,7 +449,7 @@ class ReorderRackFrontendTest(SimpleTestCase):
|
|||||||
self.assertEqual(template_name, "netbox_utilities/reorder_rack.html")
|
self.assertEqual(template_name, "netbox_utilities/reorder_rack.html")
|
||||||
self.assertEqual(context["reorder_devices"][0]["label"], "LEO-Fritzbox")
|
self.assertEqual(context["reorder_devices"][0]["label"], "LEO-Fritzbox")
|
||||||
self.assertEqual(context["reorder_devices"][0]["grid_width"], 6)
|
self.assertEqual(context["reorder_devices"][0]["grid_width"], 6)
|
||||||
self.assertEqual(context["asset_version"], "0.9.11")
|
self.assertEqual(context["asset_version"], "0.10.0")
|
||||||
self.assertIs(context["reorder_rack_width_data"], get_width_data.return_value)
|
self.assertIs(context["reorder_rack_width_data"], get_width_data.return_value)
|
||||||
get_width_data.assert_called_once()
|
get_width_data.assert_called_once()
|
||||||
self.assertIs(get_width_data.call_args.kwargs["rack"], rack)
|
self.assertIs(get_width_data.call_args.kwargs["rack"], rack)
|
||||||
|
|||||||
@@ -246,7 +246,7 @@ class TopologyViewsRackWidthTest(SimpleTestCase):
|
|||||||
self.assertIn('id="netbox-utilities-topology-rack-width-styles"', html)
|
self.assertIn('id="netbox-utilities-topology-rack-width-styles"', html)
|
||||||
self.assertIn('.rack-device[href="/dcim/devices/334/"]', html)
|
self.assertIn('.rack-device[href="/dcim/devices/334/"]', html)
|
||||||
self.assertIn("left: calc(50% + 3px) !important", html)
|
self.assertIn("left: calc(50% + 3px) !important", html)
|
||||||
self.assertIn("netbox_utilities/topology-rack-width.js?v=0.9.11", html)
|
self.assertIn("netbox_utilities/topology-rack-width.js?v=0.10.0", html)
|
||||||
self.assertIn("left:calc(0% + 3px)!important", html)
|
self.assertIn("left:calc(0% + 3px)!important", html)
|
||||||
self.assertIn("left:calc(50% + 3px)!important", html)
|
self.assertIn("left:calc(50% + 3px)!important", html)
|
||||||
self.assertIn("width:calc(50% - 6px)!important", html)
|
self.assertIn("width:calc(50% - 6px)!important", html)
|
||||||
|
|||||||
+2
-2
@@ -4,8 +4,8 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "netbox-utilities"
|
name = "netbox-utilities"
|
||||||
version = "0.9.11"
|
version = "0.10.0"
|
||||||
description = "Navigation, tenant utilities, partial-width rack devices, bulk uploads, and 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