feat: assign VLANs to network connections

This commit is contained in:
2026-08-19 12:24:40 +02:00
parent 1c6708216c
commit bd748ef985
15 changed files with 488 additions and 10 deletions
+31 -1
View File
@@ -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
+7 -2
View File
@@ -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
+137
View File
@@ -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",
},
),
]
+42
View File
@@ -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}"
+4
View File
@@ -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
+38 -3
View File
@@ -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)
+1 -1
View File
@@ -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
View File
@@ -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" }