From bd748ef98570604fb43feeadd88a5aadf90fc366 Mon Sep 17 00:00:00 2001 From: Louis Date: Wed, 19 Aug 2026 12:24:40 +0200 Subject: [PATCH] feat: assign VLANs to network connections --- README.md | 32 +++- netbox_utilities/__init__.py | 9 +- netbox_utilities/connection_vlans.py | 137 ++++++++++++++++++ .../0008_connection_vlan_assignments.py | 73 ++++++++++ netbox_utilities/models.py | 42 ++++++ netbox_utilities/runtime.py | 4 + netbox_utilities/template_content.py | 41 +++++- .../netbox_utilities/cable_edit.html | 5 + .../netbox_utilities/cable_edit_form.html | 12 ++ .../connection_vlans_panel.html | 12 ++ .../tests/test_connection_vlans.py | 115 +++++++++++++++ netbox_utilities/tests/test_netbox_export.py | 8 + netbox_utilities/tests/test_reorder_rack.py | 2 +- netbox_utilities/tests/test_topology_views.py | 2 +- pyproject.toml | 4 +- 15 files changed, 488 insertions(+), 10 deletions(-) create mode 100644 netbox_utilities/connection_vlans.py create mode 100644 netbox_utilities/migrations/0008_connection_vlan_assignments.py create mode 100644 netbox_utilities/templates/netbox_utilities/cable_edit.html create mode 100644 netbox_utilities/templates/netbox_utilities/cable_edit_form.html create mode 100644 netbox_utilities/templates/netbox_utilities/connection_vlans_panel.html create mode 100644 netbox_utilities/tests/test_connection_vlans.py diff --git a/README.md b/README.md index 5b3010a..6772d15 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/netbox_utilities/__init__.py b/netbox_utilities/__init__.py index aefeee3..3f4443e 100644 --- a/netbox_utilities/__init__.py +++ b/netbox_utilities/__init__.py @@ -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 diff --git a/netbox_utilities/connection_vlans.py b/netbox_utilities/connection_vlans.py new file mode 100644 index 0000000..a2e8ac1 --- /dev/null +++ b/netbox_utilities/connection_vlans.py @@ -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() diff --git a/netbox_utilities/migrations/0008_connection_vlan_assignments.py b/netbox_utilities/migrations/0008_connection_vlan_assignments.py new file mode 100644 index 0000000..8fe8c22 --- /dev/null +++ b/netbox_utilities/migrations/0008_connection_vlan_assignments.py @@ -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", + }, + ), + ] diff --git a/netbox_utilities/models.py b/netbox_utilities/models.py index 5d656c6..c68512b 100644 --- a/netbox_utilities/models.py +++ b/netbox_utilities/models.py @@ -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}" diff --git a/netbox_utilities/runtime.py b/netbox_utilities/runtime.py index 0631b79..c2489f5 100644 --- a/netbox_utilities/runtime.py +++ b/netbox_utilities/runtime.py @@ -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 diff --git a/netbox_utilities/template_content.py b/netbox_utilities/template_content.py index d4ef5b1..9cbfad1 100644 --- a/netbox_utilities/template_content.py +++ b/netbox_utilities/template_content.py @@ -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] diff --git a/netbox_utilities/templates/netbox_utilities/cable_edit.html b/netbox_utilities/templates/netbox_utilities/cable_edit.html new file mode 100644 index 0000000..a839768 --- /dev/null +++ b/netbox_utilities/templates/netbox_utilities/cable_edit.html @@ -0,0 +1,5 @@ +{% extends 'dcim/cable_edit.html' %} + +{% block form %} + {% include 'netbox_utilities/cable_edit_form.html' %} +{% endblock %} diff --git a/netbox_utilities/templates/netbox_utilities/cable_edit_form.html b/netbox_utilities/templates/netbox_utilities/cable_edit_form.html new file mode 100644 index 0000000..ce1af16 --- /dev/null +++ b/netbox_utilities/templates/netbox_utilities/cable_edit_form.html @@ -0,0 +1,12 @@ +{% load form_helpers %} + +{% include 'dcim/htmx/cable_edit.html' %} + +{% if form.utilities_vlans %} +
+
+

VLANs der Verbindung

+
+ {% render_field form.utilities_vlans %} +
+{% endif %} diff --git a/netbox_utilities/templates/netbox_utilities/connection_vlans_panel.html b/netbox_utilities/templates/netbox_utilities/connection_vlans_panel.html new file mode 100644 index 0000000..a95832f --- /dev/null +++ b/netbox_utilities/templates/netbox_utilities/connection_vlans_panel.html @@ -0,0 +1,12 @@ +
+

VLANs der Verbindung

+
+ {% if connection_vlans %} + {% for vlan in connection_vlans %} + {{ vlan }} + {% endfor %} + {% else %} + Keine VLANs zugeordnet + {% endif %} +
+
diff --git a/netbox_utilities/tests/test_connection_vlans.py b/netbox_utilities/tests/test_connection_vlans.py new file mode 100644 index 0000000..e191547 --- /dev/null +++ b/netbox_utilities/tests/test_connection_vlans.py @@ -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() diff --git a/netbox_utilities/tests/test_netbox_export.py b/netbox_utilities/tests/test_netbox_export.py index 52030bd..529ec60 100644 --- a/netbox_utilities/tests/test_netbox_export.py +++ b/netbox_utilities/tests/test_netbox_export.py @@ -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) diff --git a/netbox_utilities/tests/test_reorder_rack.py b/netbox_utilities/tests/test_reorder_rack.py index 7711e55..dfc7e96 100644 --- a/netbox_utilities/tests/test_reorder_rack.py +++ b/netbox_utilities/tests/test_reorder_rack.py @@ -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) diff --git a/netbox_utilities/tests/test_topology_views.py b/netbox_utilities/tests/test_topology_views.py index 5ee5074..f6f1ffd 100644 --- a/netbox_utilities/tests/test_topology_views.py +++ b/netbox_utilities/tests/test_topology_views.py @@ -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) diff --git a/pyproject.toml b/pyproject.toml index 0d3c7f8..19db81e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" }