feat: assign VLANs to network connections
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# 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.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
@@ -73,6 +74,7 @@ PLUGINS = [
|
||||
PLUGINS_CONFIG = {
|
||||
"netbox_utilities": {
|
||||
"navigation_customization_enabled": True,
|
||||
"connection_vlans_enabled": True,
|
||||
"reorder_rack_bulk_save_enabled": True,
|
||||
"topology_views_rack_width_enabled": True,
|
||||
"tenant_filter_enabled": True,
|
||||
@@ -221,6 +223,34 @@ empfohlen.
|
||||
|
||||
## Verwendung
|
||||
|
||||
### VLANs direkt an Verbindungen dokumentieren
|
||||
|
||||
Ab Version `0.10.0` erscheint beim normalen **Anlegen und Bearbeiten** einer
|
||||
Kabelverbindung oder Funkverbindung das optionale Feld **VLANs der
|
||||
Verbindung**. Darin können ein oder mehrere bestehende NetBox-VLANs ausgewählt
|
||||
werden. Die Auswahl wird außerdem auf der Detailseite der Verbindung
|
||||
angezeigt.
|
||||
|
||||
Die Zuordnung dokumentiert, welche VLANs über die jeweilige Verbindung
|
||||
transportiert werden. Sie verändert bewusst nicht automatisch die nativen
|
||||
Felder **Untagged VLAN** und **Tagged VLANs** der beteiligten Interfaces, da
|
||||
beide Enden unterschiedliche Interface-Modi besitzen können. Diese bleiben
|
||||
weiterhin die technische Konfiguration der einzelnen Interfaces.
|
||||
|
||||
Die Funktion gilt für:
|
||||
|
||||
- physische NetBox-Kabel, einschließlich Verbindungen über Patchfelder;
|
||||
- NetBox-Funkverbindungen (`WirelessLink`).
|
||||
|
||||
Leere Auswahlen erzeugen keinen Zuordnungsdatensatz. Beim Löschen einer
|
||||
Verbindung wird ihre VLAN-Zuordnung automatisch mit entfernt. Das Feature kann
|
||||
installationsweit mit `connection_vlans_enabled = False` in `PLUGINS_CONFIG`
|
||||
deaktiviert werden.
|
||||
|
||||
Ist das optionale NetBox-Export-Plugin installiert, werden diese
|
||||
VLAN-Zuordnungen als normale Plugin-Datensätze zusammen mit den Verbindungen
|
||||
und VLANs exportiert und wieder importiert.
|
||||
|
||||
### Mehrere Geräte nebeneinander in derselben HE
|
||||
|
||||
Auf der normalen Seite zum **Anlegen oder Bearbeiten eines Geräts** stehen
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
from netbox.plugins import PluginConfig, get_plugin_config
|
||||
|
||||
__version__ = "0.9.11"
|
||||
__version__ = "0.10.0"
|
||||
|
||||
|
||||
class NetBoxUtilitiesConfig(PluginConfig):
|
||||
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__
|
||||
author = "LKE"
|
||||
base_url = "utilities"
|
||||
@@ -14,6 +14,7 @@ class NetBoxUtilitiesConfig(PluginConfig):
|
||||
max_version = "4.6.99"
|
||||
default_settings = {
|
||||
"navigation_customization_enabled": True,
|
||||
"connection_vlans_enabled": True,
|
||||
"reorder_rack_bulk_save_enabled": True,
|
||||
"topology_views_rack_width_enabled": True,
|
||||
"tenant_filter_enabled": True,
|
||||
@@ -34,6 +35,10 @@ class NetBoxUtilitiesConfig(PluginConfig):
|
||||
install_tenant_validation()
|
||||
install_patchpanel_automation()
|
||||
install_rack_width_support()
|
||||
if get_plugin_config("netbox_utilities", "connection_vlans_enabled"):
|
||||
from .connection_vlans import install_connection_vlan_support
|
||||
|
||||
install_connection_vlan_support()
|
||||
if self.apps.is_installed("netbox_export"):
|
||||
from .netbox_export import install_netbox_export_rack_width_support
|
||||
|
||||
|
||||
@@ -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):
|
||||
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"))
|
||||
|
||||
|
||||
def connection_vlans_enabled():
|
||||
return bool(get_plugin_config("netbox_utilities", "connection_vlans_enabled"))
|
||||
|
||||
|
||||
def tenant_filter_enabled():
|
||||
if not get_plugin_config("netbox_utilities", "tenant_filter_enabled"):
|
||||
return False
|
||||
|
||||
@@ -5,7 +5,12 @@ from netbox.plugins import PluginTemplateExtension
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
|
||||
from . import __version__
|
||||
from .models import DeviceRackPlacement, NavigationPreference
|
||||
from .models import (
|
||||
CableVLANAssignment,
|
||||
DeviceRackPlacement,
|
||||
NavigationPreference,
|
||||
WirelessLinkVLANAssignment,
|
||||
)
|
||||
from .navigation_helpers import (
|
||||
SIDEBAR_WIDTH_DEFAULT,
|
||||
SIDEBAR_WIDTH_MAX,
|
||||
@@ -14,7 +19,7 @@ from .navigation_helpers import (
|
||||
normalize_preferences,
|
||||
normalize_sidebar_width,
|
||||
)
|
||||
from .runtime import navigation_customization_enabled, tenant_filter_enabled
|
||||
from .runtime import connection_vlans_enabled, navigation_customization_enabled, tenant_filter_enabled
|
||||
|
||||
|
||||
class UtilitiesGlobalContent(PluginTemplateExtension):
|
||||
@@ -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):
|
||||
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)
|
||||
|
||||
@@ -449,7 +449,7 @@ class ReorderRackFrontendTest(SimpleTestCase):
|
||||
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.9.11")
|
||||
self.assertEqual(context["asset_version"], "0.10.0")
|
||||
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)
|
||||
|
||||
@@ -246,7 +246,7 @@ class TopologyViewsRackWidthTest(SimpleTestCase):
|
||||
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.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(50% + 3px)!important", html)
|
||||
self.assertIn("width:calc(50% - 6px)!important", html)
|
||||
|
||||
+2
-2
@@ -4,8 +4,8 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "netbox-utilities"
|
||||
version = "0.9.11"
|
||||
description = "Navigation, tenant utilities, partial-width rack devices, bulk uploads, and rack reordering for NetBox 4.6"
|
||||
version = "0.10.0"
|
||||
description = "Navigation, tenant utilities, connection VLANs, partial-width racks, and bulk operations for NetBox 4.6"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
license = { text = "MIT" }
|
||||
|
||||
Reference in New Issue
Block a user