diff --git a/netbox_librenms_plugin/tests/test_background_jobs.py b/netbox_librenms_plugin/tests/test_background_jobs.py
index 372da26e95..13dca842d8 100644
--- a/netbox_librenms_plugin/tests/test_background_jobs.py
+++ b/netbox_librenms_plugin/tests/test_background_jobs.py
@@ -15,11 +15,13 @@ class TestShouldUseBackgroundJob:
"""Test background job decision logic."""
def test_checkbox_checked_returns_true(self):
- """When use_background_job form field is True, return True."""
+ """When use_background_job form field is True, return True for superusers."""
from netbox_librenms_plugin.views.imports.list import LibreNMSImportView
view = LibreNMSImportView()
view._filter_form_data = {"use_background_job": True}
+ view.request = MagicMock()
+ view.request.user.is_superuser = True
assert view.should_use_background_job() is True
@@ -29,27 +31,45 @@ def test_checkbox_unchecked_returns_false(self):
view = LibreNMSImportView()
view._filter_form_data = {"use_background_job": False}
+ view.request = MagicMock()
+ view.request.user.is_superuser = True
assert view.should_use_background_job() is False
def test_default_when_field_missing(self):
- """When field is missing, default to True."""
+ """When field is missing, default to True for superusers."""
from netbox_librenms_plugin.views.imports.list import LibreNMSImportView
view = LibreNMSImportView()
view._filter_form_data = {"some_other_field": "value"}
+ view.request = MagicMock()
+ view.request.user.is_superuser = True
assert view.should_use_background_job() is True
def test_empty_form_data_returns_default(self):
- """Empty form data returns default True."""
+ """Empty form data returns default True for superusers."""
from netbox_librenms_plugin.views.imports.list import LibreNMSImportView
view = LibreNMSImportView()
view._filter_form_data = {}
+ view.request = MagicMock()
+ view.request.user.is_superuser = True
assert view.should_use_background_job() is True
+ def test_non_superuser_always_returns_false(self):
+ """Non-superuser users always get synchronous mode."""
+ from netbox_librenms_plugin.views.imports.list import LibreNMSImportView
+
+ view = LibreNMSImportView()
+ view._filter_form_data = {"use_background_job": True}
+ view.request = MagicMock()
+ view.request.user.is_superuser = False
+
+ # Even when checkbox is True, non-superusers get False
+ assert view.should_use_background_job() is False
+
def create_mock_job_runner(job_class, job_pk=123):
"""Create a mock job runner instance without invoking real __init__."""
diff --git a/netbox_librenms_plugin/tests/test_interface_vlan_sync.py b/netbox_librenms_plugin/tests/test_interface_vlan_sync.py
new file mode 100644
index 0000000000..04a7503bb1
--- /dev/null
+++ b/netbox_librenms_plugin/tests/test_interface_vlan_sync.py
@@ -0,0 +1,563 @@
+"""
+Tests for interface VLAN sync functionality (Phase 2).
+
+Tests cover:
+- VlanAssignmentMixin methods
+- Port VLAN enrichment
+- VLAN sync action
+"""
+
+from unittest.mock import MagicMock, patch
+
+# Import the autouse fixture from helpers
+pytest_plugins = ["netbox_librenms_plugin.tests.test_librenms_api_helpers"]
+
+
+class TestVlanAssignmentMixin:
+ """Tests for VlanAssignmentMixin methods."""
+
+ def test_get_vlan_groups_for_device_includes_site_scoped(self, mock_librenms_config):
+ """Test that VLAN groups scoped to device's site are included."""
+ from netbox_librenms_plugin.views.mixins import VlanAssignmentMixin
+
+ mixin = VlanAssignmentMixin()
+
+ # Create mock device with site
+ mock_device = MagicMock()
+ mock_device.site = MagicMock()
+ mock_device.site.pk = 1
+ mock_device.site.region = None
+ mock_device.site.group = None
+ mock_device.location = None
+ mock_device.rack = None
+
+ # Mock the VLAN group query
+ mock_site_group = MagicMock()
+ mock_site_group.name = "Site VLANs"
+ mock_site_group.pk = 10
+
+ with patch.object(mixin, "_get_vlan_groups_for_scope") as mock_get_scope:
+ mock_get_scope.return_value = [mock_site_group]
+ with patch("ipam.models.VLANGroup") as mock_vlan_group_class:
+ mock_vlan_group_class.objects.filter.return_value = []
+
+ mixin.get_vlan_groups_for_device(mock_device)
+
+ # Verify site scope was queried
+ assert mock_get_scope.called
+
+ def test_get_vlan_groups_for_device_includes_global(self, mock_librenms_config):
+ """Test that global VLAN groups (no scope) are included."""
+ from netbox_librenms_plugin.views.mixins import VlanAssignmentMixin
+
+ mixin = VlanAssignmentMixin()
+
+ # Create mock device with no location context
+ mock_device = MagicMock()
+ mock_device.site = None
+ mock_device.location = None
+ mock_device.rack = None
+
+ with patch.object(mixin, "_get_vlan_groups_for_scope") as mock_get_scope:
+ mock_get_scope.return_value = []
+ with patch("ipam.models.VLANGroup") as mock_vlan_group_class:
+ mock_global_group = MagicMock()
+ mock_global_group.name = "Global VLANs"
+ mock_global_group.pk = 20
+ mock_vlan_group_class.objects.filter.return_value = [mock_global_group]
+
+ mixin.get_vlan_groups_for_device(mock_device)
+
+ # Verify global scope was queried
+ mock_vlan_group_class.objects.filter.assert_called_with(scope_type__isnull=True)
+
+ def test_select_most_specific_group_prefers_rack(self, mock_librenms_config):
+ """Test that rack-scoped groups are preferred over site-scoped."""
+ from netbox_librenms_plugin.views.mixins import VlanAssignmentMixin
+
+ mixin = VlanAssignmentMixin()
+
+ # Create mock device with rack
+ mock_device = MagicMock()
+ mock_device.rack = MagicMock()
+ mock_device.rack.pk = 1
+ mock_device.site = MagicMock()
+ mock_device.site.pk = 2
+ mock_device.site.region = None
+ mock_device.site.group = None
+ mock_device.location = None
+
+ # Create mock groups with different scopes
+ mock_rack_group = MagicMock()
+ mock_rack_group.scope_type = MagicMock()
+ mock_rack_group.scope_type.pk = 100 # Rack content type
+ mock_rack_group.scope_id = 1
+
+ mock_site_group = MagicMock()
+ mock_site_group.scope_type = MagicMock()
+ mock_site_group.scope_type.pk = 101 # Site content type
+ mock_site_group.scope_id = 2
+
+ with patch("django.contrib.contenttypes.models.ContentType") as mock_ct:
+ # Mock ContentType lookups
+ mock_ct.objects.get_for_model.side_effect = lambda model: MagicMock(pk=100 if "Rack" in str(model) else 101)
+
+ result = mixin._select_most_specific_group([mock_rack_group, mock_site_group], mock_device)
+
+ # Rack-scoped should be preferred
+ assert result == mock_rack_group
+
+ def test_select_most_specific_group_returns_none_for_ambiguous(self, mock_librenms_config):
+ """Test that None is returned when multiple groups have same priority."""
+ from netbox_librenms_plugin.views.mixins import VlanAssignmentMixin
+
+ mixin = VlanAssignmentMixin()
+
+ # Create mock device
+ mock_device = MagicMock()
+ mock_device.site = MagicMock()
+ mock_device.site.pk = 1
+ mock_device.site.region = None
+ mock_device.site.group = None
+ mock_device.rack = None
+ mock_device.location = None
+
+ # Create two groups with same scope (both site-scoped to same site)
+ mock_group1 = MagicMock()
+ mock_group1.scope_type = MagicMock()
+ mock_group1.scope_type.pk = 101
+ mock_group1.scope_id = 1
+
+ mock_group2 = MagicMock()
+ mock_group2.scope_type = MagicMock()
+ mock_group2.scope_type.pk = 101
+ mock_group2.scope_id = 1
+
+ with patch("django.contrib.contenttypes.models.ContentType") as mock_ct:
+ mock_ct.objects.get_for_model.return_value = MagicMock(pk=101)
+
+ result = mixin._select_most_specific_group([mock_group1, mock_group2], mock_device)
+
+ # Ambiguous - should return None
+ assert result is None
+
+ def test_get_ancestors_returns_hierarchy(self, mock_librenms_config):
+ """Test that _get_ancestors returns full parent chain."""
+ from netbox_librenms_plugin.views.mixins import VlanAssignmentMixin
+
+ mixin = VlanAssignmentMixin()
+
+ # Create mock location hierarchy
+ mock_grandparent = MagicMock()
+ mock_grandparent.parent = None
+
+ mock_parent = MagicMock()
+ mock_parent.parent = mock_grandparent
+
+ mock_location = MagicMock()
+ mock_location.parent = mock_parent
+
+ ancestors = mixin._get_ancestors(mock_location)
+
+ assert len(ancestors) == 3
+ assert ancestors[0] == mock_location
+ assert ancestors[1] == mock_parent
+ assert ancestors[2] == mock_grandparent
+
+ def test_find_vlan_in_group_prefers_specified_group(self, mock_librenms_config):
+ """Test that _find_vlan_in_group prefers the specified group."""
+ from netbox_librenms_plugin.views.mixins import VlanAssignmentMixin
+
+ mixin = VlanAssignmentMixin()
+
+ mock_vlan_in_group = MagicMock()
+ mock_vlan_global = MagicMock()
+
+ lookup_maps = {
+ "vid_group_to_vlan": {
+ (100, 5): mock_vlan_in_group,
+ (100, None): mock_vlan_global,
+ },
+ "vid_to_vlans": {
+ 100: [mock_vlan_in_group, mock_vlan_global],
+ },
+ }
+
+ result = mixin._find_vlan_in_group(100, 5, lookup_maps)
+
+ assert result == mock_vlan_in_group
+
+ def test_find_vlan_in_group_falls_back_to_global(self, mock_librenms_config):
+ """Test that _find_vlan_in_group falls back to global VLAN."""
+ from netbox_librenms_plugin.views.mixins import VlanAssignmentMixin
+
+ mixin = VlanAssignmentMixin()
+
+ mock_vlan_global = MagicMock()
+
+ lookup_maps = {
+ "vid_group_to_vlan": {
+ (100, None): mock_vlan_global,
+ },
+ "vid_to_vlans": {
+ 100: [mock_vlan_global],
+ },
+ }
+
+ # Request group 5 which doesn't have VLAN 100
+ result = mixin._find_vlan_in_group(100, 5, lookup_maps)
+
+ assert result == mock_vlan_global
+
+ def test_find_vlan_in_group_returns_none_if_not_found(self, mock_librenms_config):
+ """Test that _find_vlan_in_group returns None if VLAN not found."""
+ from netbox_librenms_plugin.views.mixins import VlanAssignmentMixin
+
+ mixin = VlanAssignmentMixin()
+
+ lookup_maps = {
+ "vid_group_to_vlan": {},
+ "vid_to_vlans": {},
+ }
+
+ result = mixin._find_vlan_in_group(999, None, lookup_maps)
+
+ assert result is None
+
+
+class TestPortVlanEnrichment:
+ """Tests for port VLAN data enrichment."""
+
+ pytest_plugins = ["tests.test_librenms_api_helpers"]
+
+ @patch("requests.get")
+ def test_parse_port_vlan_data_access_port(self, mock_get, mock_librenms_config):
+ """Test parsing access port VLAN data."""
+ from netbox_librenms_plugin.librenms_api import LibreNMSAPI
+
+ api = LibreNMSAPI(server_key="default")
+
+ port_data = {
+ "port_id": 1234,
+ "ifName": "Gi1/0/1",
+ "ifDescr": "GigabitEthernet1/0/1",
+ "ifVlan": "100",
+ "ifTrunk": None,
+ }
+
+ result = api.parse_port_vlan_data(port_data, "ifName")
+
+ assert result["port_id"] == 1234
+ assert result["interface_name"] == "Gi1/0/1"
+ assert result["mode"] == "access"
+ assert result["untagged_vlan"] == 100
+ assert result["tagged_vlans"] == []
+
+ @patch("requests.get")
+ def test_parse_port_vlan_data_trunk_port(self, mock_get, mock_librenms_config):
+ """Test parsing trunk port VLAN data."""
+ from netbox_librenms_plugin.librenms_api import LibreNMSAPI
+
+ api = LibreNMSAPI(server_key="default")
+
+ port_data = {
+ "port_id": 5678,
+ "ifName": "Te1/1/1",
+ "ifDescr": "TenGigabitEthernet1/1/1",
+ "ifVlan": "90",
+ "ifTrunk": "dot1Q",
+ "vlans": [
+ {"vlan": 90, "untagged": 1, "state": "unknown"},
+ {"vlan": 50, "untagged": 0, "state": "forwarding"},
+ {"vlan": 60, "untagged": 0, "state": "forwarding"},
+ ],
+ }
+
+ result = api.parse_port_vlan_data(port_data, "ifName")
+
+ assert result["port_id"] == 5678
+ assert result["interface_name"] == "Te1/1/1"
+ assert result["mode"] == "tagged"
+ assert result["untagged_vlan"] == 90
+ assert sorted(result["tagged_vlans"]) == [50, 60]
+
+ @patch("requests.get")
+ def test_parse_port_vlan_data_uses_interface_name_field(self, mock_get, mock_librenms_config):
+ """Test that parse_port_vlan_data respects interface_name_field parameter."""
+ from netbox_librenms_plugin.librenms_api import LibreNMSAPI
+
+ api = LibreNMSAPI(server_key="default")
+
+ port_data = {
+ "port_id": 1234,
+ "ifName": "Gi1/0/1",
+ "ifDescr": "GigabitEthernet1/0/1",
+ "ifVlan": "100",
+ "ifTrunk": None,
+ }
+
+ result = api.parse_port_vlan_data(port_data, "ifDescr")
+
+ assert result["interface_name"] == "GigabitEthernet1/0/1"
+
+
+class TestInterfaceVlanSync:
+ """Tests for interface VLAN sync action."""
+
+ pytest_plugins = ["tests.test_librenms_api_helpers"]
+
+ def test_update_interface_vlan_assignment_access_mode(self, mock_librenms_config):
+ """Test that access mode is set correctly for untagged-only ports."""
+ from netbox_librenms_plugin.views.mixins import VlanAssignmentMixin
+
+ mixin = VlanAssignmentMixin()
+
+ mock_interface = MagicMock()
+ mock_interface.tagged_vlans = MagicMock()
+
+ mock_vlan = MagicMock()
+ mock_vlan.vid = 100
+
+ lookup_maps = {
+ "vid_group_to_vlan": {(100, None): mock_vlan},
+ "vid_to_vlans": {100: [mock_vlan]},
+ }
+
+ vlan_data = {
+ "untagged_vlan": 100,
+ "tagged_vlans": [],
+ }
+
+ mixin._update_interface_vlan_assignment(mock_interface, vlan_data, None, lookup_maps)
+
+ assert mock_interface.mode == "access"
+ assert mock_interface.untagged_vlan == mock_vlan
+ mock_interface.tagged_vlans.clear.assert_called_once()
+
+ def test_update_interface_vlan_assignment_tagged_mode(self, mock_librenms_config):
+ """Test that tagged mode is set for trunk ports."""
+ from netbox_librenms_plugin.views.mixins import VlanAssignmentMixin
+
+ mixin = VlanAssignmentMixin()
+
+ mock_interface = MagicMock()
+ mock_interface.tagged_vlans = MagicMock()
+
+ mock_vlan_100 = MagicMock()
+ mock_vlan_100.vid = 100
+ mock_vlan_200 = MagicMock()
+ mock_vlan_200.vid = 200
+ mock_vlan_300 = MagicMock()
+ mock_vlan_300.vid = 300
+
+ lookup_maps = {
+ "vid_group_to_vlan": {
+ (100, None): mock_vlan_100,
+ (200, None): mock_vlan_200,
+ (300, None): mock_vlan_300,
+ },
+ "vid_to_vlans": {
+ 100: [mock_vlan_100],
+ 200: [mock_vlan_200],
+ 300: [mock_vlan_300],
+ },
+ }
+
+ vlan_data = {
+ "untagged_vlan": 100,
+ "tagged_vlans": [200, 300],
+ }
+
+ mixin._update_interface_vlan_assignment(mock_interface, vlan_data, None, lookup_maps)
+
+ assert mock_interface.mode == "tagged"
+ assert mock_interface.untagged_vlan == mock_vlan_100
+ mock_interface.tagged_vlans.set.assert_called_once_with([mock_vlan_200, mock_vlan_300])
+
+ def test_update_interface_vlan_assignment_missing_vlans(self, mock_librenms_config):
+ """Test that missing VLANs are tracked in result."""
+ from netbox_librenms_plugin.views.mixins import VlanAssignmentMixin
+
+ mixin = VlanAssignmentMixin()
+
+ mock_interface = MagicMock()
+ mock_interface.tagged_vlans = MagicMock()
+
+ # Empty lookup maps - no VLANs exist in NetBox
+ lookup_maps = {
+ "vid_group_to_vlan": {},
+ "vid_to_vlans": {},
+ }
+
+ vlan_data = {
+ "untagged_vlan": 100,
+ "tagged_vlans": [200, 300],
+ }
+
+ result = mixin._update_interface_vlan_assignment(mock_interface, vlan_data, None, lookup_maps)
+
+ assert result["missing_vlans"] == [100, 200, 300]
+ assert mock_interface.untagged_vlan is None
+ mock_interface.tagged_vlans.set.assert_called_once_with([])
+
+ def test_update_interface_vlan_assignment_respects_group_selection(self, mock_librenms_config):
+ """Test that VLAN group selection is respected."""
+ from netbox_librenms_plugin.views.mixins import VlanAssignmentMixin
+
+ mixin = VlanAssignmentMixin()
+
+ mock_interface = MagicMock()
+ mock_interface.tagged_vlans = MagicMock()
+
+ mock_vlan_group1 = MagicMock()
+ mock_vlan_group1.vid = 100
+ mock_vlan_global = MagicMock()
+ mock_vlan_global.vid = 100
+
+ lookup_maps = {
+ "vid_group_to_vlan": {
+ (100, 5): mock_vlan_group1,
+ (100, None): mock_vlan_global,
+ },
+ "vid_to_vlans": {
+ 100: [mock_vlan_group1, mock_vlan_global],
+ },
+ }
+
+ vlan_data = {
+ "untagged_vlan": 100,
+ "tagged_vlans": [],
+ }
+
+ # Request VLAN from group 5
+ mixin._update_interface_vlan_assignment(mock_interface, vlan_data, 5, lookup_maps)
+
+ # Should use group-specific VLAN
+ assert mock_interface.untagged_vlan == mock_vlan_group1
+
+
+class TestInterfaceCssClassGroupMatching:
+ """
+ Tests for group-aware VLAN CSS class functions in utils.py.
+
+ Verifies that VLAN group mismatch (same VID but different group) produces
+ orange (text-warning) instead of green (text-success).
+ """
+
+ # -- get_untagged_vlan_css_class --
+
+ def test_untagged_vid_match_group_match_returns_green(self, mock_librenms_config):
+ from netbox_librenms_plugin.utils import get_untagged_vlan_css_class
+
+ assert get_untagged_vlan_css_class(60, 60, True, [], group_matches=True) == "text-success"
+
+ def test_untagged_vid_match_group_mismatch_returns_orange(self, mock_librenms_config):
+ from netbox_librenms_plugin.utils import get_untagged_vlan_css_class
+
+ assert get_untagged_vlan_css_class(60, 60, True, [], group_matches=False) == "text-warning"
+
+ def test_untagged_vid_differs_group_irrelevant(self, mock_librenms_config):
+ """Different VIDs -> text-warning regardless of group_matches."""
+ from netbox_librenms_plugin.utils import get_untagged_vlan_css_class
+
+ assert get_untagged_vlan_css_class(60, 100, True, [], group_matches=True) == "text-warning"
+
+ def test_untagged_not_in_netbox_ignores_group(self, mock_librenms_config):
+ from netbox_librenms_plugin.utils import get_untagged_vlan_css_class
+
+ assert get_untagged_vlan_css_class(60, 60, False, [], group_matches=True) == "text-danger"
+
+ def test_untagged_missing_vlan_ignores_group(self, mock_librenms_config):
+ from netbox_librenms_plugin.utils import get_untagged_vlan_css_class
+
+ assert get_untagged_vlan_css_class(60, 60, True, [60], group_matches=True) == "text-danger"
+
+ def test_untagged_no_netbox_vlan_returns_red(self, mock_librenms_config):
+ from netbox_librenms_plugin.utils import get_untagged_vlan_css_class
+
+ assert get_untagged_vlan_css_class(60, None, True, [], group_matches=True) == "text-danger"
+
+ def test_untagged_default_group_matches_is_true(self, mock_librenms_config):
+ """Without group_matches param, defaults to True (backward compat)."""
+ from netbox_librenms_plugin.utils import get_untagged_vlan_css_class
+
+ assert get_untagged_vlan_css_class(60, 60, True, []) == "text-success"
+
+ # -- get_tagged_vlan_css_class --
+
+ def test_tagged_vid_present_group_match_returns_green(self, mock_librenms_config):
+ from netbox_librenms_plugin.utils import get_tagged_vlan_css_class
+
+ assert get_tagged_vlan_css_class(60, {60, 100}, True, [], group_matches=True) == "text-success"
+
+ def test_tagged_vid_present_group_mismatch_returns_orange(self, mock_librenms_config):
+ from netbox_librenms_plugin.utils import get_tagged_vlan_css_class
+
+ assert get_tagged_vlan_css_class(60, {60, 100}, True, [], group_matches=False) == "text-warning"
+
+ def test_tagged_vid_absent_group_irrelevant(self, mock_librenms_config):
+ from netbox_librenms_plugin.utils import get_tagged_vlan_css_class
+
+ assert get_tagged_vlan_css_class(60, {100}, True, [], group_matches=True) == "text-danger"
+
+ def test_tagged_not_in_netbox_ignores_group(self, mock_librenms_config):
+ from netbox_librenms_plugin.utils import get_tagged_vlan_css_class
+
+ assert get_tagged_vlan_css_class(60, {60}, False, [], group_matches=True) == "text-danger"
+
+ def test_tagged_missing_vlan_ignores_group(self, mock_librenms_config):
+ from netbox_librenms_plugin.utils import get_tagged_vlan_css_class
+
+ assert get_tagged_vlan_css_class(60, {60}, True, [60], group_matches=True) == "text-danger"
+
+ def test_tagged_default_group_matches_is_true(self, mock_librenms_config):
+ """Without group_matches param, defaults to True (backward compat)."""
+ from netbox_librenms_plugin.utils import get_tagged_vlan_css_class
+
+ assert get_tagged_vlan_css_class(60, {60}, True, []) == "text-success"
+
+ # -- check_vlan_group_matches --
+
+ def test_check_group_matches_untagged_same_group(self, mock_librenms_config):
+ from netbox_librenms_plugin.utils import check_vlan_group_matches
+
+ assert check_vlan_group_matches("U", 60, 5, 5, {}, 60, set()) is True
+
+ def test_check_group_matches_untagged_different_group(self, mock_librenms_config):
+ from netbox_librenms_plugin.utils import check_vlan_group_matches
+
+ assert check_vlan_group_matches("U", 60, 10, 5, {}, 60, set()) is False
+
+ def test_check_group_matches_untagged_vid_differs(self, mock_librenms_config):
+ """When VIDs don't match, group comparison is irrelevant -> True."""
+ from netbox_librenms_plugin.utils import check_vlan_group_matches
+
+ assert check_vlan_group_matches("U", 60, 10, 5, {}, 100, set()) is True
+
+ def test_check_group_matches_tagged_same_group(self, mock_librenms_config):
+ from netbox_librenms_plugin.utils import check_vlan_group_matches
+
+ assert check_vlan_group_matches("T", 60, 5, None, {60: 5}, None, {60}) is True
+
+ def test_check_group_matches_tagged_different_group(self, mock_librenms_config):
+ from netbox_librenms_plugin.utils import check_vlan_group_matches
+
+ assert check_vlan_group_matches("T", 60, 10, None, {60: 5}, None, {60}) is False
+
+ def test_check_group_matches_tagged_vid_absent(self, mock_librenms_config):
+ """When VID is not tagged in NetBox, group comparison irrelevant -> True."""
+ from netbox_librenms_plugin.utils import check_vlan_group_matches
+
+ assert check_vlan_group_matches("T", 60, 10, None, {}, None, set()) is True
+
+ def test_check_group_matches_global_to_global(self, mock_librenms_config):
+ """Both NetBox VLAN and selected have no group (global) -> match."""
+ from netbox_librenms_plugin.utils import check_vlan_group_matches
+
+ assert check_vlan_group_matches("U", 60, None, None, {}, 60, set()) is True
+
+ def test_check_group_matches_global_vs_group(self, mock_librenms_config):
+ """NetBox VLAN is global, selected is a specific group -> mismatch."""
+ from netbox_librenms_plugin.utils import check_vlan_group_matches
+
+ assert check_vlan_group_matches("U", 60, 5, None, {}, 60, set()) is False
diff --git a/netbox_librenms_plugin/tests/test_librenms_api.py b/netbox_librenms_plugin/tests/test_librenms_api.py
index d48cfb7571..5ce115f340 100644
--- a/netbox_librenms_plugin/tests/test_librenms_api.py
+++ b/netbox_librenms_plugin/tests/test_librenms_api.py
@@ -67,6 +67,29 @@ def test_init_missing_config_raises_valueerror(self, mock_librenms_config):
with pytest.raises(ValueError):
LibreNMSAPI(server_key="nonexistent")
+ def test_init_nonexistent_server_key_raises_keyerror(self, mock_librenms_config):
+ """Verify KeyError raised when specific server_key doesn't exist."""
+ from netbox_librenms_plugin.librenms_api import LibreNMSAPI
+
+ with pytest.raises(KeyError, match="nonexistent"):
+ LibreNMSAPI(server_key="nonexistent")
+
+ def test_init_default_falls_back_to_first_server(self, mock_librenms_config):
+ """Verify 'default' key falls back to first configured server."""
+ mock_config = mock_librenms_config["mock_config"]
+ mock_config.return_value = {
+ "primary": {
+ "librenms_url": "https://primary.example.com",
+ "api_token": "primary-token",
+ }
+ }
+
+ from netbox_librenms_plugin.librenms_api import LibreNMSAPI
+
+ api = LibreNMSAPI(server_key="default")
+ assert api.server_key == "primary"
+ assert api.librenms_url == "https://primary.example.com"
+
# =============================================================================
# Test Class 2: Connection Testing (4 tests)
@@ -589,6 +612,34 @@ def test_add_device_success(self, mock_post, mock_librenms_config):
assert result[0] is True
assert result[1] == "Device added successfully."
+ @patch("netbox_librenms_plugin.librenms_api.requests.post")
+ def test_add_device_snmpv1_success(self, mock_post, mock_librenms_config):
+ """Verify successful device addition using SNMPv1."""
+ mock_post.return_value.status_code = 200
+ mock_post.return_value.json.return_value = {
+ "status": "ok",
+ "message": "Device added successfully",
+ }
+
+ from netbox_librenms_plugin.librenms_api import LibreNMSAPI
+
+ api = LibreNMSAPI(server_key="default")
+ result = api.add_device(
+ data={
+ "hostname": "legacy-device.example.com",
+ "snmp_version": "v1",
+ "community": "public",
+ }
+ )
+
+ assert result[0] is True
+ assert result[1] == "Device added successfully."
+ # Verify the payload includes correct snmpver and community
+ call_args = mock_post.call_args
+ payload = call_args.kwargs.get("json") or call_args[1].get("json")
+ assert payload["snmpver"] == "v1"
+ assert payload["community"] == "public"
+
@patch("netbox_librenms_plugin.librenms_api.requests.post")
def test_add_device_duplicate_error(self, mock_post, mock_librenms_config):
"""Verify duplicate device handling."""
@@ -612,6 +663,46 @@ def test_add_device_duplicate_error(self, mock_post, mock_librenms_config):
assert result[0] is False
assert "Device already exists" in result[1]
+ @patch("netbox_librenms_plugin.librenms_api.requests.post")
+ def test_add_device_snmpv3_success(self, mock_post, mock_librenms_config):
+ """Verify successful device addition using SNMPv3 with all required fields."""
+ mock_post.return_value.status_code = 200
+ mock_post.return_value.json.return_value = {
+ "status": "ok",
+ "message": "Device added successfully",
+ }
+
+ from netbox_librenms_plugin.librenms_api import LibreNMSAPI
+
+ api = LibreNMSAPI(server_key="default")
+ result = api.add_device(
+ data={
+ "hostname": "secure-device.example.com",
+ "snmp_version": "v3",
+ "authlevel": "authPriv",
+ "authname": "snmpuser",
+ "authpass": "authpassword123",
+ "authalgo": "SHA",
+ "cryptopass": "cryptopassword456",
+ "cryptoalgo": "AES",
+ }
+ )
+
+ assert result[0] is True
+ assert result[1] == "Device added successfully."
+ # Verify the payload includes correct snmpver and all v3 fields
+ call_args = mock_post.call_args
+ payload = call_args.kwargs.get("json") or call_args[1].get("json")
+ assert payload["snmpver"] == "v3"
+ assert payload["authlevel"] == "authPriv"
+ assert payload["authname"] == "snmpuser"
+ assert payload["authpass"] == "authpassword123"
+ assert payload["authalgo"] == "SHA"
+ assert payload["cryptopass"] == "cryptopassword456"
+ assert payload["cryptoalgo"] == "AES"
+ # Ensure community is NOT included for v3
+ assert "community" not in payload
+
@patch("netbox_librenms_plugin.librenms_api.requests.patch")
def test_update_device_field_success(self, mock_patch, mock_librenms_config):
"""Verify successful device field update."""
diff --git a/netbox_librenms_plugin/tests/test_permissions.py b/netbox_librenms_plugin/tests/test_permissions.py
new file mode 100644
index 0000000000..d366965ead
--- /dev/null
+++ b/netbox_librenms_plugin/tests/test_permissions.py
@@ -0,0 +1,953 @@
+from unittest.mock import MagicMock, patch
+
+
+class TestLibreNMSPermissionMixin:
+ """Tests for permission mixin functionality."""
+
+ def test_has_write_permission_granted(self):
+ """User with change permission has write access."""
+ from netbox_librenms_plugin.views.mixins import LibreNMSPermissionMixin
+
+ mixin = LibreNMSPermissionMixin()
+ mixin.request = MagicMock()
+ mixin.request.user.has_perm.return_value = True
+
+ assert mixin.has_write_permission() is True
+
+ def test_has_write_permission_denied(self):
+ """User without change permission lacks write access."""
+ from netbox_librenms_plugin.views.mixins import LibreNMSPermissionMixin
+
+ mixin = LibreNMSPermissionMixin()
+ mixin.request = MagicMock()
+ mixin.request.user.has_perm.return_value = False
+
+ assert mixin.has_write_permission() is False
+
+ def test_require_write_permission_allowed(self):
+ """User with write permission gets None (allowed to proceed)."""
+ from netbox_librenms_plugin.views.mixins import LibreNMSPermissionMixin
+
+ mixin = LibreNMSPermissionMixin()
+ mixin.request = MagicMock()
+ mixin.request.user.has_perm.return_value = True
+
+ result = mixin.require_write_permission()
+ assert result is None
+
+ def test_require_write_permission_denied(self):
+ """User without write permission gets redirect response to referrer."""
+ from netbox_librenms_plugin.views.mixins import LibreNMSPermissionMixin
+
+ mixin = LibreNMSPermissionMixin()
+ mixin.request = MagicMock()
+ mixin.request.user.has_perm.return_value = False
+ mixin.request.path = "/some/path/"
+ mixin.request.META = {"HTTP_REFERER": "/original/page/"}
+ mixin.request.get_host.return_value = "testserver"
+ mixin.request.is_secure.return_value = False
+ mixin.request.headers = {} # Not an HTMX request
+
+ with patch("netbox_librenms_plugin.views.mixins.redirect") as mock_redirect:
+ with patch("netbox_librenms_plugin.views.mixins.messages"):
+ result = mixin.require_write_permission()
+
+ mock_redirect.assert_called_once_with("/original/page/")
+ assert result is not None
+
+ def test_require_write_permission_denied_htmx(self):
+ """HTMX request without write permission gets HX-Redirect response."""
+ from netbox_librenms_plugin.views.mixins import LibreNMSPermissionMixin
+
+ mixin = LibreNMSPermissionMixin()
+ mixin.request = MagicMock()
+ mixin.request.user.has_perm.return_value = False
+ mixin.request.path = "/some/path/"
+ mixin.request.META = {"HTTP_REFERER": "/original/page/"}
+ mixin.request.get_host.return_value = "testserver"
+ mixin.request.is_secure.return_value = False
+ mixin.request.headers = {"HX-Request": "true"}
+
+ with patch("netbox_librenms_plugin.views.mixins.messages"):
+ result = mixin.require_write_permission()
+
+ # Should return HttpResponse with HX-Redirect header
+ assert result is not None
+ assert result["HX-Redirect"] == "/original/page/"
+
+ def test_require_write_permission_json_allowed(self):
+ """User with write permission gets None (allowed to proceed)."""
+ from netbox_librenms_plugin.views.mixins import LibreNMSPermissionMixin
+
+ mixin = LibreNMSPermissionMixin()
+ mixin.request = MagicMock()
+ mixin.request.user.has_perm.return_value = True
+
+ result = mixin.require_write_permission_json()
+ assert result is None
+
+ def test_require_write_permission_json_denied(self):
+ """User without write permission gets JsonResponse with 403."""
+ import json
+ from netbox_librenms_plugin.views.mixins import LibreNMSPermissionMixin
+
+ mixin = LibreNMSPermissionMixin()
+ mixin.request = MagicMock()
+ mixin.request.user.has_perm.return_value = False
+
+ result = mixin.require_write_permission_json()
+
+ assert result is not None
+ assert result.status_code == 403
+ content = json.loads(result.content)
+ assert content["error"] == "You do not have permission to perform this action."
+
+ def test_require_write_permission_json_custom_message(self):
+ """Custom error message is returned in JsonResponse."""
+ import json
+ from netbox_librenms_plugin.views.mixins import LibreNMSPermissionMixin
+
+ mixin = LibreNMSPermissionMixin()
+ mixin.request = MagicMock()
+ mixin.request.user.has_perm.return_value = False
+
+ result = mixin.require_write_permission_json(error_message="Custom denied message")
+
+ assert result is not None
+ assert result.status_code == 403
+ content = json.loads(result.content)
+ assert content["error"] == "Custom denied message"
+
+
+class TestAPIPermissions:
+ """Tests for API permission class."""
+
+ def test_get_requires_view_permission(self):
+ """GET requests require view permission."""
+ from netbox_librenms_plugin.api.views import LibreNMSPluginPermission
+ from netbox_librenms_plugin.constants import PERM_VIEW_PLUGIN
+
+ permission = LibreNMSPluginPermission()
+ request = MagicMock()
+ request.method = "GET"
+ request.user.has_perm.return_value = True
+
+ assert permission.has_permission(request, None) is True
+ request.user.has_perm.assert_called_with(PERM_VIEW_PLUGIN)
+
+ def test_post_requires_change_permission(self):
+ """POST requests require change permission."""
+ from netbox_librenms_plugin.api.views import LibreNMSPluginPermission
+ from netbox_librenms_plugin.constants import PERM_CHANGE_PLUGIN
+
+ permission = LibreNMSPluginPermission()
+ request = MagicMock()
+ request.method = "POST"
+ request.user.has_perm.return_value = True
+
+ assert permission.has_permission(request, None) is True
+ request.user.has_perm.assert_called_with(PERM_CHANGE_PLUGIN)
+
+ def test_put_requires_change_permission(self):
+ """PUT requests require change permission."""
+ from netbox_librenms_plugin.api.views import LibreNMSPluginPermission
+ from netbox_librenms_plugin.constants import PERM_CHANGE_PLUGIN
+
+ permission = LibreNMSPluginPermission()
+ request = MagicMock()
+ request.method = "PUT"
+ request.user.has_perm.return_value = True
+
+ assert permission.has_permission(request, None) is True
+ request.user.has_perm.assert_called_with(PERM_CHANGE_PLUGIN)
+
+ def test_delete_requires_change_permission(self):
+ """DELETE requests require change permission."""
+ from netbox_librenms_plugin.api.views import LibreNMSPluginPermission
+ from netbox_librenms_plugin.constants import PERM_CHANGE_PLUGIN
+
+ permission = LibreNMSPluginPermission()
+ request = MagicMock()
+ request.method = "DELETE"
+ request.user.has_perm.return_value = True
+
+ assert permission.has_permission(request, None) is True
+ request.user.has_perm.assert_called_with(PERM_CHANGE_PLUGIN)
+
+ def test_get_denied_without_view_permission(self):
+ """GET requests denied without view permission."""
+ from netbox_librenms_plugin.api.views import LibreNMSPluginPermission
+
+ permission = LibreNMSPluginPermission()
+ request = MagicMock()
+ request.method = "GET"
+ request.user.has_perm.return_value = False
+
+ assert permission.has_permission(request, None) is False
+
+ def test_post_denied_without_change_permission(self):
+ """POST requests denied without change permission."""
+ from netbox_librenms_plugin.api.views import LibreNMSPluginPermission
+
+ permission = LibreNMSPluginPermission()
+ request = MagicMock()
+ request.method = "POST"
+ request.user.has_perm.return_value = False
+
+ assert permission.has_permission(request, None) is False
+
+
+class TestPermissionConstants:
+ """Tests for permission constants."""
+
+ def test_view_permission_constant(self):
+ """View permission constant is correct."""
+ from netbox_librenms_plugin.constants import PERM_VIEW_PLUGIN
+
+ assert PERM_VIEW_PLUGIN == "netbox_librenms_plugin.view_librenmssettings"
+
+ def test_change_permission_constant(self):
+ """Change permission constant is correct."""
+ from netbox_librenms_plugin.constants import PERM_CHANGE_PLUGIN
+
+ assert PERM_CHANGE_PLUGIN == "netbox_librenms_plugin.change_librenmssettings"
+
+
+# =============================================================================
+# Phase 2: Object Permission Tests
+# =============================================================================
+
+
+class TestObjectPermissionHelpers:
+ """Tests for Phase 2 object permission helper functions."""
+
+ def test_check_user_permissions_all_granted(self):
+ """Returns True when user has all permissions."""
+ from netbox_librenms_plugin.import_utils import check_user_permissions
+
+ user = MagicMock()
+ user.has_perm.return_value = True
+
+ has_all, missing = check_user_permissions(user, ["dcim.add_device", "dcim.add_interface"])
+
+ assert has_all is True
+ assert missing == []
+ assert user.has_perm.call_count == 2
+
+ def test_check_user_permissions_some_missing(self):
+ """Returns False with list of missing permissions."""
+ from netbox_librenms_plugin.import_utils import check_user_permissions
+
+ user = MagicMock()
+ user.has_perm.side_effect = lambda p: p != "dcim.add_interface"
+
+ has_all, missing = check_user_permissions(user, ["dcim.add_device", "dcim.add_interface"])
+
+ assert has_all is False
+ assert missing == ["dcim.add_interface"]
+
+ def test_check_user_permissions_all_missing(self):
+ """Returns False with all permissions listed as missing."""
+ from netbox_librenms_plugin.import_utils import check_user_permissions
+
+ user = MagicMock()
+ user.has_perm.return_value = False
+
+ has_all, missing = check_user_permissions(user, ["dcim.add_device", "dcim.add_interface"])
+
+ assert has_all is False
+ assert "dcim.add_device" in missing
+ assert "dcim.add_interface" in missing
+
+ def test_check_user_permissions_no_user(self):
+ """Raises PermissionDenied when user is None."""
+ import pytest
+ from django.core.exceptions import PermissionDenied
+
+ from netbox_librenms_plugin.import_utils import check_user_permissions
+
+ with pytest.raises(PermissionDenied, match="No user context"):
+ check_user_permissions(None, ["dcim.add_device"])
+
+ def test_require_permissions_passes_when_granted(self):
+ """Does not raise when user has all permissions."""
+ from netbox_librenms_plugin.import_utils import require_permissions
+
+ user = MagicMock()
+ user.has_perm.return_value = True
+
+ # Should not raise
+ require_permissions(user, ["dcim.add_device", "dcim.add_interface"], "import devices")
+
+ def test_require_permissions_raises_on_missing(self):
+ """Raises PermissionDenied with descriptive message."""
+ import pytest
+ from django.core.exceptions import PermissionDenied
+
+ from netbox_librenms_plugin.import_utils import require_permissions
+
+ user = MagicMock()
+ user.has_perm.return_value = False
+
+ with pytest.raises(PermissionDenied) as exc_info:
+ require_permissions(user, ["dcim.add_device"], "import devices")
+
+ # Check error message contains action description and missing permission
+ assert "import devices" in str(exc_info.value)
+ assert "dcim.add_device" in str(exc_info.value)
+
+ def test_require_permissions_lists_multiple_missing(self):
+ """Error message includes all missing permissions."""
+ import pytest
+ from django.core.exceptions import PermissionDenied
+
+ from netbox_librenms_plugin.import_utils import require_permissions
+
+ user = MagicMock()
+ user.has_perm.return_value = False
+
+ with pytest.raises(PermissionDenied) as exc_info:
+ require_permissions(
+ user,
+ ["dcim.add_device", "dcim.add_interface"],
+ "import devices",
+ )
+
+ error_msg = str(exc_info.value)
+ assert "dcim.add_device" in error_msg
+ assert "dcim.add_interface" in error_msg
+
+
+class TestNetBoxObjectPermissionMixin:
+ """Tests for the NetBoxObjectPermissionMixin class."""
+
+ def test_check_object_permissions_all_granted(self):
+ """Returns True when user has all object permissions."""
+ from netbox_librenms_plugin.views.mixins import NetBoxObjectPermissionMixin
+
+ mixin = NetBoxObjectPermissionMixin()
+ mixin.request = MagicMock()
+ mixin.request.user.has_perm.return_value = True
+
+ mock_model = MagicMock()
+ mixin.required_object_permissions = {
+ "POST": [("add", mock_model), ("change", mock_model)],
+ }
+
+ with patch("netbox_librenms_plugin.views.mixins.get_permission_for_model") as mock_get:
+ mock_get.side_effect = ["dcim.add_interface", "dcim.change_interface"]
+ has_all, missing = mixin.check_object_permissions("POST")
+
+ assert has_all is True
+ assert missing == []
+
+ def test_check_object_permissions_some_missing(self):
+ """Returns False with missing permission strings."""
+ from netbox_librenms_plugin.views.mixins import NetBoxObjectPermissionMixin
+
+ mixin = NetBoxObjectPermissionMixin()
+ mixin.request = MagicMock()
+ mixin.request.user.has_perm.side_effect = lambda p: p != "dcim.add_interface"
+
+ mock_model = MagicMock()
+ mixin.required_object_permissions = {
+ "POST": [("add", mock_model)],
+ }
+
+ with patch("netbox_librenms_plugin.views.mixins.get_permission_for_model") as mock_get:
+ mock_get.return_value = "dcim.add_interface"
+ has_all, missing = mixin.check_object_permissions("POST")
+
+ assert has_all is False
+ assert "dcim.add_interface" in missing
+
+ def test_check_object_permissions_no_requirements(self):
+ """Returns True when no permissions required for method."""
+ from netbox_librenms_plugin.views.mixins import NetBoxObjectPermissionMixin
+
+ mixin = NetBoxObjectPermissionMixin()
+ mixin.request = MagicMock()
+ mixin.required_object_permissions = {} # No requirements
+
+ has_all, missing = mixin.check_object_permissions("POST")
+
+ assert has_all is True
+ assert missing == []
+
+ def test_require_object_permissions_returns_none_when_granted(self):
+ """Returns None when all permissions are granted."""
+ from netbox_librenms_plugin.views.mixins import NetBoxObjectPermissionMixin
+
+ mixin = NetBoxObjectPermissionMixin()
+ mixin.request = MagicMock()
+ mixin.request.user.has_perm.return_value = True
+
+ mock_model = MagicMock()
+ mixin.required_object_permissions = {
+ "POST": [("add", mock_model)],
+ }
+
+ with patch("netbox_librenms_plugin.views.mixins.get_permission_for_model") as mock_get:
+ mock_get.return_value = "dcim.add_cable"
+ response = mixin.require_object_permissions("POST")
+
+ assert response is None
+
+ def test_require_object_permissions_returns_redirect_response(self):
+ """Returns redirect response with message when permissions missing."""
+ from netbox_librenms_plugin.views.mixins import NetBoxObjectPermissionMixin
+
+ mixin = NetBoxObjectPermissionMixin()
+ mixin.request = MagicMock()
+ mixin.request.user.has_perm.return_value = False
+ mixin.request.path = "/original/page/"
+ mixin.request.META = {"HTTP_REFERER": "/original/page/"}
+ mixin.request.get_host.return_value = "testserver"
+ mixin.request.is_secure.return_value = False
+ mixin.request.headers = {} # Not an HTMX request
+
+ mock_model = MagicMock()
+ mixin.required_object_permissions = {
+ "POST": [("add", mock_model)],
+ }
+
+ with patch("netbox_librenms_plugin.views.mixins.get_permission_for_model") as mock_get:
+ with patch("netbox_librenms_plugin.views.mixins.messages") as mock_messages:
+ with patch("netbox_librenms_plugin.views.mixins.redirect") as mock_redirect:
+ mock_get.return_value = "dcim.add_cable"
+ response = mixin.require_object_permissions("POST")
+
+ assert response is not None
+ # Verify error message was added
+ mock_messages.error.assert_called_once()
+ error_msg = mock_messages.error.call_args[0][1]
+ assert "dcim.add_cable" in error_msg
+ # Verify redirect was called
+ mock_redirect.assert_called_once_with("/original/page/")
+
+ def test_require_object_permissions_htmx_returns_hx_redirect(self):
+ """HTMX request returns HX-Redirect header when permissions missing."""
+ from netbox_librenms_plugin.views.mixins import NetBoxObjectPermissionMixin
+
+ mixin = NetBoxObjectPermissionMixin()
+ mixin.request = MagicMock()
+ mixin.request.user.has_perm.return_value = False
+ mixin.request.path = "/original/page/"
+ mixin.request.META = {"HTTP_REFERER": "/original/page/"}
+ mixin.request.get_host.return_value = "testserver"
+ mixin.request.is_secure.return_value = False
+ mixin.request.headers = {"HX-Request": "true"}
+
+ mock_model = MagicMock()
+ mixin.required_object_permissions = {
+ "POST": [("add", mock_model)],
+ }
+
+ with patch("netbox_librenms_plugin.views.mixins.get_permission_for_model") as mock_get:
+ with patch("netbox_librenms_plugin.views.mixins.messages"):
+ mock_get.return_value = "dcim.add_cable"
+ response = mixin.require_object_permissions("POST")
+
+ assert response is not None
+ assert response["HX-Redirect"] == "/original/page/"
+
+ def test_require_object_permissions_json_allowed(self):
+ """Returns None when all object permissions are granted."""
+ from netbox_librenms_plugin.views.mixins import NetBoxObjectPermissionMixin
+
+ mixin = NetBoxObjectPermissionMixin()
+ mixin.request = MagicMock()
+ mixin.request.user.has_perm.return_value = True
+
+ mock_model = MagicMock()
+ mixin.required_object_permissions = {
+ "POST": [("delete", mock_model)],
+ }
+
+ with patch("netbox_librenms_plugin.views.mixins.get_permission_for_model") as mock_get:
+ mock_get.return_value = "dcim.delete_interface"
+ response = mixin.require_object_permissions_json("POST")
+
+ assert response is None
+
+ def test_require_object_permissions_json_denied(self):
+ """Returns JsonResponse with 403 when object permissions missing."""
+ import json
+
+ from netbox_librenms_plugin.views.mixins import NetBoxObjectPermissionMixin
+
+ mixin = NetBoxObjectPermissionMixin()
+ mixin.request = MagicMock()
+ mixin.request.user.has_perm.return_value = False
+
+ mock_model = MagicMock()
+ mixin.required_object_permissions = {
+ "POST": [("delete", mock_model)],
+ }
+
+ with patch("netbox_librenms_plugin.views.mixins.get_permission_for_model") as mock_get:
+ mock_get.return_value = "dcim.delete_interface"
+ response = mixin.require_object_permissions_json("POST")
+
+ assert response is not None
+ assert response.status_code == 403
+ content = json.loads(response.content)
+ assert "dcim.delete_interface" in content["error"]
+
+ def test_require_all_permissions_allowed(self):
+ """Returns None when both write and object permissions granted."""
+ from netbox_librenms_plugin.views.mixins import (
+ LibreNMSPermissionMixin,
+ NetBoxObjectPermissionMixin,
+ )
+
+ class TestView(LibreNMSPermissionMixin, NetBoxObjectPermissionMixin):
+ pass
+
+ mixin = TestView()
+ mixin.request = MagicMock()
+ mixin.request.user.has_perm.return_value = True
+
+ mock_model = MagicMock()
+ mixin.required_object_permissions = {
+ "POST": [("change", mock_model)],
+ }
+
+ with patch("netbox_librenms_plugin.views.mixins.get_permission_for_model") as mock_get:
+ mock_get.return_value = "dcim.change_device"
+ response = mixin.require_all_permissions("POST")
+
+ assert response is None
+
+ def test_require_all_permissions_denied_write(self):
+ """Returns error when write permission denied (doesn't check object perms)."""
+ from netbox_librenms_plugin.views.mixins import (
+ LibreNMSPermissionMixin,
+ NetBoxObjectPermissionMixin,
+ )
+
+ class TestView(LibreNMSPermissionMixin, NetBoxObjectPermissionMixin):
+ pass
+
+ mixin = TestView()
+ mixin.request = MagicMock()
+ mixin.request.user.has_perm.return_value = False
+ mixin.request.path = "/original/page/"
+ mixin.request.META = {"HTTP_REFERER": "/original/page/"}
+ mixin.request.get_host.return_value = "testserver"
+ mixin.request.is_secure.return_value = False
+ mixin.request.headers = {}
+
+ mixin.required_object_permissions = {"POST": []}
+
+ with patch("netbox_librenms_plugin.views.mixins.redirect") as mock_redirect:
+ with patch("netbox_librenms_plugin.views.mixins.messages"):
+ response = mixin.require_all_permissions("POST")
+
+ assert response is not None
+ mock_redirect.assert_called_once_with("/original/page/")
+
+ def test_require_all_permissions_denied_object(self):
+ """Returns error when object permissions denied (write passes)."""
+ from netbox_librenms_plugin.views.mixins import (
+ LibreNMSPermissionMixin,
+ NetBoxObjectPermissionMixin,
+ )
+
+ class TestView(LibreNMSPermissionMixin, NetBoxObjectPermissionMixin):
+ pass
+
+ mixin = TestView()
+ mixin.request = MagicMock()
+ # has_write_permission passes, but object perms fail
+ mixin.request.user.has_perm.side_effect = lambda p: p == "netbox_librenms_plugin.change_librenmssettings"
+ mixin.request.path = "/original/page/"
+ mixin.request.META = {"HTTP_REFERER": "/original/page/"}
+ mixin.request.get_host.return_value = "testserver"
+ mixin.request.is_secure.return_value = False
+ mixin.request.headers = {}
+
+ mock_model = MagicMock()
+ mixin.required_object_permissions = {
+ "POST": [("add", mock_model)],
+ }
+
+ with patch("netbox_librenms_plugin.views.mixins.get_permission_for_model") as mock_get:
+ with patch("netbox_librenms_plugin.views.mixins.messages"):
+ with patch("netbox_librenms_plugin.views.mixins.redirect") as mock_redirect:
+ mock_get.return_value = "dcim.add_device"
+ response = mixin.require_all_permissions("POST")
+
+ assert response is not None
+ mock_redirect.assert_called_once()
+
+ def test_require_all_permissions_json_allowed(self):
+ """Returns None when both write and object permissions granted (JSON variant)."""
+ from netbox_librenms_plugin.views.mixins import (
+ LibreNMSPermissionMixin,
+ NetBoxObjectPermissionMixin,
+ )
+
+ class TestView(LibreNMSPermissionMixin, NetBoxObjectPermissionMixin):
+ pass
+
+ mixin = TestView()
+ mixin.request = MagicMock()
+ mixin.request.user.has_perm.return_value = True
+
+ mock_model = MagicMock()
+ mixin.required_object_permissions = {
+ "POST": [("delete", mock_model)],
+ }
+
+ with patch("netbox_librenms_plugin.views.mixins.get_permission_for_model") as mock_get:
+ mock_get.return_value = "dcim.delete_interface"
+ response = mixin.require_all_permissions_json("POST")
+
+ assert response is None
+
+ def test_require_all_permissions_json_denied_write(self):
+ """Returns JSON 403 when write permission denied (JSON variant)."""
+ import json
+
+ from netbox_librenms_plugin.views.mixins import (
+ LibreNMSPermissionMixin,
+ NetBoxObjectPermissionMixin,
+ )
+
+ class TestView(LibreNMSPermissionMixin, NetBoxObjectPermissionMixin):
+ pass
+
+ mixin = TestView()
+ mixin.request = MagicMock()
+ mixin.request.user.has_perm.return_value = False
+
+ response = mixin.require_all_permissions_json("POST")
+
+ assert response is not None
+ assert response.status_code == 403
+ content = json.loads(response.content)
+ assert "error" in content
+
+
+class TestBulkImportPermissions:
+ """Tests for permission checks in bulk import functions."""
+
+ @patch("netbox_librenms_plugin.import_utils.require_permissions")
+ @patch("netbox_librenms_plugin.import_utils.LibreNMSAPI")
+ def test_bulk_import_devices_checks_permissions(self, mock_api_class, mock_require):
+ """bulk_import_devices_shared calls require_permissions."""
+ from netbox_librenms_plugin.import_utils import bulk_import_devices_shared
+
+ user = MagicMock()
+ mock_api = MagicMock()
+ mock_api_class.return_value = mock_api
+
+ # Set up API to return empty device so loop completes quickly
+ mock_api.get_device_info.return_value = (False, None)
+
+ bulk_import_devices_shared(
+ device_ids=[1],
+ user=user,
+ server_key="default",
+ )
+
+ mock_require.assert_called_once()
+ call_args = mock_require.call_args
+ assert user == call_args[0][0]
+ assert "dcim.add_device" in call_args[0][1]
+ assert "dcim.add_interface" in call_args[0][1]
+
+ @patch("netbox_librenms_plugin.import_utils.require_permissions")
+ @patch("netbox_librenms_plugin.import_utils.LibreNMSAPI")
+ def test_bulk_import_devices_extracts_user_from_job(self, mock_api_class, mock_require):
+ """bulk_import_devices_shared extracts user from job if not provided."""
+ from netbox_librenms_plugin.import_utils import bulk_import_devices_shared
+
+ job_user = MagicMock()
+ job = MagicMock()
+ job.job.user = job_user
+
+ mock_api = MagicMock()
+ mock_api_class.return_value = mock_api
+ mock_api.get_device_info.return_value = (False, None)
+
+ bulk_import_devices_shared(
+ device_ids=[1],
+ job=job,
+ server_key="default",
+ )
+
+ mock_require.assert_called_once()
+ call_args = mock_require.call_args
+ assert job_user == call_args[0][0]
+
+ @patch("netbox_librenms_plugin.import_utils.require_permissions")
+ def test_bulk_import_vms_checks_permissions(self, mock_require):
+ """bulk_import_vms calls require_permissions."""
+ from netbox_librenms_plugin.import_utils import bulk_import_vms
+
+ user = MagicMock()
+ api = MagicMock()
+ api.server_key = "default"
+
+ # Empty vm_imports to complete quickly
+ bulk_import_vms(
+ vm_imports={},
+ api=api,
+ user=user,
+ )
+
+ mock_require.assert_called_once()
+ call_args = mock_require.call_args
+ assert user == call_args[0][0]
+ assert "virtualization.add_virtualmachine" in call_args[0][1]
+
+ @patch("netbox_librenms_plugin.import_utils.require_permissions")
+ def test_bulk_import_vms_extracts_user_from_job(self, mock_require):
+ """bulk_import_vms extracts user from job if not provided."""
+ from netbox_librenms_plugin.import_utils import bulk_import_vms
+
+ job_user = MagicMock()
+ job = MagicMock()
+ job.job.user = job_user
+
+ api = MagicMock()
+ api.server_key = "default"
+
+ bulk_import_vms(
+ vm_imports={},
+ api=api,
+ job=job,
+ )
+
+ mock_require.assert_called_once()
+ call_args = mock_require.call_args
+ assert job_user == call_args[0][0]
+
+
+class TestBulkImportPermissionDenied:
+ """Tests for permission denied behavior in bulk import."""
+
+ @patch("netbox_librenms_plugin.import_utils.check_user_permissions")
+ def test_bulk_import_devices_raises_on_missing_permissions(self, mock_check):
+ """bulk_import_devices_shared raises PermissionDenied when permissions missing."""
+ import pytest
+ from django.core.exceptions import PermissionDenied
+
+ from netbox_librenms_plugin.import_utils import bulk_import_devices_shared
+
+ mock_check.return_value = (False, ["dcim.add_device"])
+
+ user = MagicMock()
+
+ with pytest.raises(PermissionDenied):
+ bulk_import_devices_shared(
+ device_ids=[1],
+ user=user,
+ server_key="default",
+ )
+
+ @patch("netbox_librenms_plugin.import_utils.check_user_permissions")
+ def test_bulk_import_vms_raises_on_missing_permissions(self, mock_check):
+ """bulk_import_vms raises PermissionDenied when permissions missing."""
+ import pytest
+ from django.core.exceptions import PermissionDenied
+
+ from netbox_librenms_plugin.import_utils import bulk_import_vms
+
+ mock_check.return_value = (False, ["virtualization.add_virtualmachine"])
+
+ user = MagicMock()
+ api = MagicMock()
+
+ with pytest.raises(PermissionDenied):
+ bulk_import_vms(
+ vm_imports={1: {"cluster_id": 1}},
+ api=api,
+ user=user,
+ )
+
+
+class TestSafeRedirectUrl:
+ """Tests for the _get_safe_redirect_url helper."""
+
+ def test_internal_referrer_is_accepted(self):
+ """Internal referrer URL is returned when host matches."""
+ from netbox_librenms_plugin.views.mixins import _get_safe_redirect_url
+
+ request = MagicMock()
+ request.META = {"HTTP_REFERER": "http://testserver/some/page/"}
+ request.get_host.return_value = "testserver"
+ request.is_secure.return_value = False
+ request.path = "/fallback/"
+
+ result = _get_safe_redirect_url(request)
+ assert result == "http://testserver/some/page/"
+
+ def test_external_referrer_is_rejected(self):
+ """External referrer URL is rejected, falls back to request.path."""
+ from netbox_librenms_plugin.views.mixins import _get_safe_redirect_url
+
+ request = MagicMock()
+ request.META = {"HTTP_REFERER": "http://evil.com/attack"}
+ request.get_host.return_value = "testserver"
+ request.is_secure.return_value = False
+ request.path = "/safe/fallback/"
+
+ result = _get_safe_redirect_url(request)
+ assert result == "/safe/fallback/"
+
+ def test_no_referrer_falls_back_to_path(self):
+ """Missing referrer falls back to request.path."""
+ from netbox_librenms_plugin.views.mixins import _get_safe_redirect_url
+
+ request = MagicMock()
+ request.META = {}
+ request.path = "/current/page/"
+
+ result = _get_safe_redirect_url(request)
+ assert result == "/current/page/"
+
+ def test_no_referrer_no_path_falls_back_to_slash(self):
+ """Missing referrer and no path attribute falls back to '/'."""
+ from netbox_librenms_plugin.views.mixins import _get_safe_redirect_url
+
+ request = MagicMock(spec=[]) # No attributes at all
+ request.META = {}
+
+ result = _get_safe_redirect_url(request)
+ assert result == "/"
+
+ def test_relative_referrer_is_accepted(self):
+ """Relative referrer path is accepted (no host to mismatch)."""
+ from netbox_librenms_plugin.views.mixins import _get_safe_redirect_url
+
+ request = MagicMock()
+ request.META = {"HTTP_REFERER": "/original/page/"}
+ request.get_host.return_value = "testserver"
+ request.is_secure.return_value = False
+ request.path = "/fallback/"
+
+ result = _get_safe_redirect_url(request)
+ assert result == "/original/page/"
+
+ def test_write_permission_denied_rejects_external_referrer(self):
+ """Write permission denial with external referrer falls back to request.path."""
+ from netbox_librenms_plugin.views.mixins import LibreNMSPermissionMixin
+
+ mixin = LibreNMSPermissionMixin()
+ mixin.request = MagicMock()
+ mixin.request.user.has_perm.return_value = False
+ mixin.request.path = "/safe/page/"
+ mixin.request.META = {"HTTP_REFERER": "http://evil.com/steal"}
+ mixin.request.get_host.return_value = "testserver"
+ mixin.request.is_secure.return_value = False
+ mixin.request.headers = {}
+
+ with patch("netbox_librenms_plugin.views.mixins.redirect") as mock_redirect:
+ with patch("netbox_librenms_plugin.views.mixins.messages"):
+ mixin.require_write_permission()
+
+ mock_redirect.assert_called_once_with("/safe/page/")
+
+ def test_htmx_rejects_external_referrer(self):
+ """HTMX request with external referrer uses fallback in HX-Redirect."""
+ from netbox_librenms_plugin.views.mixins import LibreNMSPermissionMixin
+
+ mixin = LibreNMSPermissionMixin()
+ mixin.request = MagicMock()
+ mixin.request.user.has_perm.return_value = False
+ mixin.request.path = "/safe/page/"
+ mixin.request.META = {"HTTP_REFERER": "http://evil.com/steal"}
+ mixin.request.get_host.return_value = "testserver"
+ mixin.request.is_secure.return_value = False
+ mixin.request.headers = {"HX-Request": "true"}
+
+ with patch("netbox_librenms_plugin.views.mixins.messages"):
+ result = mixin.require_write_permission()
+
+ assert result["HX-Redirect"] == "/safe/page/"
+
+
+class TestBulkImportVCPermission:
+ """Tests that bulk import checks virtualchassis permission."""
+
+ @patch("netbox_librenms_plugin.import_utils.require_permissions")
+ @patch("netbox_librenms_plugin.import_utils.LibreNMSAPI")
+ def test_bulk_import_devices_checks_vc_permission(self, mock_api_class, mock_require):
+ """bulk_import_devices_shared includes dcim.add_virtualchassis in required perms."""
+ from netbox_librenms_plugin.import_utils import bulk_import_devices_shared
+
+ user = MagicMock()
+ mock_api = MagicMock()
+ mock_api_class.return_value = mock_api
+ mock_api.get_device_info.return_value = (False, None)
+
+ bulk_import_devices_shared(
+ device_ids=[1],
+ user=user,
+ server_key="default",
+ )
+
+ mock_require.assert_called_once()
+ call_args = mock_require.call_args
+ assert "dcim.add_virtualchassis" in call_args[0][1]
+
+
+class TestObjectTypeValidation:
+ """Tests that get_required_permissions_for_object_type validates object_type."""
+
+ def test_sync_interfaces_device_type(self):
+ """SyncInterfacesView returns correct perms for device type."""
+ from netbox_librenms_plugin.views.sync.interfaces import SyncInterfacesView
+
+ view = SyncInterfacesView()
+ perms = view.get_required_permissions_for_object_type("device")
+ assert len(perms) == 2
+
+ def test_sync_interfaces_vm_type(self):
+ """SyncInterfacesView returns correct perms for virtualmachine type."""
+ from netbox_librenms_plugin.views.sync.interfaces import SyncInterfacesView
+
+ view = SyncInterfacesView()
+ perms = view.get_required_permissions_for_object_type("virtualmachine")
+ assert len(perms) == 2
+
+ def test_sync_interfaces_invalid_type_raises_404(self):
+ """SyncInterfacesView raises Http404 for invalid object type."""
+ import pytest
+ from django.http import Http404
+
+ from netbox_librenms_plugin.views.sync.interfaces import SyncInterfacesView
+
+ view = SyncInterfacesView()
+ with pytest.raises(Http404):
+ view.get_required_permissions_for_object_type("invalid")
+
+ def test_delete_interfaces_device_type(self):
+ """DeleteNetBoxInterfacesView returns correct perms for device type."""
+ from netbox_librenms_plugin.views.sync.interfaces import DeleteNetBoxInterfacesView
+
+ view = DeleteNetBoxInterfacesView()
+ perms = view.get_required_permissions_for_object_type("device")
+ assert len(perms) == 1
+
+ def test_delete_interfaces_vm_type(self):
+ """DeleteNetBoxInterfacesView returns correct perms for virtualmachine type."""
+ from netbox_librenms_plugin.views.sync.interfaces import DeleteNetBoxInterfacesView
+
+ view = DeleteNetBoxInterfacesView()
+ perms = view.get_required_permissions_for_object_type("virtualmachine")
+ assert len(perms) == 1
+
+ def test_delete_interfaces_invalid_type_raises_404(self):
+ """DeleteNetBoxInterfacesView raises Http404 for invalid object type."""
+ import pytest
+ from django.http import Http404
+
+ from netbox_librenms_plugin.views.sync.interfaces import DeleteNetBoxInterfacesView
+
+ view = DeleteNetBoxInterfacesView()
+ with pytest.raises(Http404):
+ view.get_required_permissions_for_object_type("invalid")
diff --git a/netbox_librenms_plugin/tests/test_sync_view_mismatch.py b/netbox_librenms_plugin/tests/test_sync_view_mismatch.py
new file mode 100644
index 0000000000..e59ab6909c
--- /dev/null
+++ b/netbox_librenms_plugin/tests/test_sync_view_mismatch.py
@@ -0,0 +1,320 @@
+"""Tests for device mismatch detection in get_librenms_device_info.
+
+Covers the identity cross-matching logic that determines whether a
+mismatched_device warning banner is shown on the LibreNMS Sync page.
+
+Match rule: mismatch is False when ANY NetBox identity (device name,
+primary IP, DNS name) matches ANY LibreNMS identity (sysName, hostname, ip).
+"""
+
+from unittest.mock import MagicMock, patch
+
+
+def _make_view(librenms_id, device_info, librenms_url="https://librenms.example.com"):
+ """Create a minimal BaseLibreNMSSyncView instance with mocked dependencies."""
+ from netbox_librenms_plugin.views.base.librenms_sync_view import BaseLibreNMSSyncView
+
+ view = object.__new__(BaseLibreNMSSyncView)
+ view.librenms_id = librenms_id
+ api = MagicMock()
+ api.librenms_url = librenms_url
+ api.get_device_info.return_value = (True, device_info)
+ api.get_device_inventory.return_value = (True, [])
+ view._librenms_api = api
+ return view
+
+
+def _make_obj(name, primary_ip=None, dns_name=None, virtual_chassis=None, cf=None):
+ """Create a mock NetBox device object."""
+ obj = MagicMock()
+ obj.name = name
+ obj.cf = cf or {}
+ if primary_ip:
+ obj.primary_ip = MagicMock()
+ obj.primary_ip.address.ip = primary_ip
+ obj.primary_ip.dns_name = dns_name or ""
+ else:
+ obj.primary_ip = None
+ obj.virtual_chassis = virtual_chassis
+ return obj
+
+
+class TestMismatchDetection:
+ """Tests for identity cross-matching logic."""
+
+ # -- No device / API failure -------------------------------------------
+
+ @patch("netbox_librenms_plugin.views.base.librenms_sync_view.match_librenms_hardware_to_device_type")
+ def test_no_librenms_id_returns_not_found(self, mock_hw):
+ """No librenms_id means device is not found."""
+ view = _make_view(librenms_id=None, device_info=None)
+ result = view.get_librenms_device_info(_make_obj("sw01"))
+
+ assert result["found_in_librenms"] is False
+ assert result["mismatched_device"] is False
+
+ @patch("netbox_librenms_plugin.views.base.librenms_sync_view.match_librenms_hardware_to_device_type")
+ def test_api_failure_returns_not_found(self, mock_hw):
+ """API failure (success=False) means device is not found."""
+ view = _make_view(librenms_id=42, device_info=None)
+ view.librenms_api.get_device_info.return_value = (False, None)
+ result = view.get_librenms_device_info(_make_obj("sw01"))
+
+ assert result["found_in_librenms"] is False
+ assert result["mismatched_device"] is False
+
+ # -- Name matches ------------------------------------------------------
+
+ @patch("netbox_librenms_plugin.views.base.librenms_sync_view.match_librenms_hardware_to_device_type")
+ def test_exact_sysname_match(self, mock_hw):
+ """NetBox name matches LibreNMS sysName (case-insensitive)."""
+ view = _make_view(42, {"sysName": "SW01", "ip": "10.0.0.2"})
+ obj = _make_obj("sw01", primary_ip="10.0.0.1")
+ result = view.get_librenms_device_info(obj)
+
+ assert result["found_in_librenms"] is True
+ assert result["mismatched_device"] is False
+
+ @patch("netbox_librenms_plugin.views.base.librenms_sync_view.match_librenms_hardware_to_device_type")
+ def test_netbox_name_matches_librenms_hostname(self, mock_hw):
+ """NetBox name matches LibreNMS hostname field."""
+ view = _make_view(42, {"sysName": "something-else", "hostname": "sw01", "ip": "10.0.0.2"})
+ obj = _make_obj("sw01", primary_ip="10.0.0.1")
+ result = view.get_librenms_device_info(obj)
+
+ assert result["found_in_librenms"] is True
+ assert result["mismatched_device"] is False
+
+ @patch("netbox_librenms_plugin.views.base.librenms_sync_view.match_librenms_hardware_to_device_type")
+ def test_fqdn_match(self, mock_hw):
+ """Full FQDN match -- no mismatch."""
+ view = _make_view(42, {"sysName": "sw01.example.net", "ip": "10.0.0.2"})
+ obj = _make_obj("sw01.example.net", primary_ip="10.0.0.1")
+ result = view.get_librenms_device_info(obj)
+
+ assert result["found_in_librenms"] is True
+ assert result["mismatched_device"] is False
+
+ # -- IP matches --------------------------------------------------------
+
+ @patch("netbox_librenms_plugin.views.base.librenms_sync_view.match_librenms_hardware_to_device_type")
+ def test_netbox_ip_matches_librenms_ip(self, mock_hw):
+ """NetBox primary IP matches LibreNMS IP -- no mismatch."""
+ view = _make_view(42, {"sysName": "different", "ip": "10.0.0.1"})
+ obj = _make_obj("sw01", primary_ip="10.0.0.1")
+ result = view.get_librenms_device_info(obj)
+
+ assert result["found_in_librenms"] is True
+ assert result["mismatched_device"] is False
+
+ @patch("netbox_librenms_plugin.views.base.librenms_sync_view.match_librenms_hardware_to_device_type")
+ def test_netbox_ip_matches_librenms_hostname_ip(self, mock_hw):
+ """LibreNMS hostname is an IP that matches NetBox primary IP."""
+ view = _make_view(42, {"sysName": "different", "hostname": "10.0.0.1", "ip": "10.0.0.1"})
+ obj = _make_obj("sw01", primary_ip="10.0.0.1")
+ result = view.get_librenms_device_info(obj)
+
+ assert result["found_in_librenms"] is True
+ assert result["mismatched_device"] is False
+
+ # -- DNS name matches --------------------------------------------------
+
+ @patch("netbox_librenms_plugin.views.base.librenms_sync_view.match_librenms_hardware_to_device_type")
+ def test_dns_name_matches_sysname(self, mock_hw):
+ """NetBox DNS name matches LibreNMS sysName."""
+ view = _make_view(42, {"sysName": "sw01.example.net", "ip": "10.0.0.2"})
+ obj = _make_obj("sw01", primary_ip="10.0.0.1", dns_name="sw01.example.net")
+ result = view.get_librenms_device_info(obj)
+
+ assert result["found_in_librenms"] is True
+ assert result["mismatched_device"] is False
+
+ @patch("netbox_librenms_plugin.views.base.librenms_sync_view.match_librenms_hardware_to_device_type")
+ def test_dns_name_matches_librenms_hostname(self, mock_hw):
+ """NetBox DNS name matches LibreNMS hostname field."""
+ view = _make_view(42, {"sysName": "something", "hostname": "sw01.example.net", "ip": "10.0.0.2"})
+ obj = _make_obj("sw01", primary_ip="10.0.0.1", dns_name="sw01.example.net")
+ result = view.get_librenms_device_info(obj)
+
+ assert result["found_in_librenms"] is True
+ assert result["mismatched_device"] is False
+
+ # -- Mismatches --------------------------------------------------------
+
+ @patch("netbox_librenms_plugin.views.base.librenms_sync_view.match_librenms_hardware_to_device_type")
+ def test_completely_different_is_mismatch(self, mock_hw):
+ """No identities overlap -- mismatch."""
+ view = _make_view(42, {"sysName": "router-01", "hostname": "router-01.corp", "ip": "10.0.0.2"})
+ obj = _make_obj("switch-05", primary_ip="10.0.0.1", dns_name="switch-05.corp")
+ result = view.get_librenms_device_info(obj)
+
+ assert result["found_in_librenms"] is True
+ assert result["mismatched_device"] is True
+
+ @patch("netbox_librenms_plugin.views.base.librenms_sync_view.match_librenms_hardware_to_device_type")
+ def test_short_vs_fqdn_matches_via_domain_strip(self, mock_hw):
+ """Short name vs FQDN -- matches after domain stripping."""
+ view = _make_view(42, {"sysName": "sw01.example.net", "ip": "10.0.0.2"})
+ obj = _make_obj("sw01", primary_ip="10.0.0.1")
+ result = view.get_librenms_device_info(obj)
+
+ assert result["found_in_librenms"] is True
+ assert result["mismatched_device"] is False
+
+ @patch("netbox_librenms_plugin.views.base.librenms_sync_view.match_librenms_hardware_to_device_type")
+ def test_fqdn_domain_differs_matches_via_domain_strip(self, mock_hw):
+ """Different FQDN domains -- matches because domain-stripped
+ LibreNMS short name 'sw01' matches NetBox FQDN split 'sw01'.
+
+ NetBox name 'sw01.example.net' is compared as-is (no stripping),
+ but the LibreNMS domain-stripped 'sw01' does NOT appear in the
+ NetBox identities since NetBox names are not domain-stripped.
+ However, both sides share the short name via NetBox raw name
+ normalization β actually NetBox keeps the full name.
+ """
+ view = _make_view(42, {"sysName": "sw01.other.net", "ip": "10.0.0.2"})
+ obj = _make_obj("sw01.example.net", primary_ip="10.0.0.1")
+ result = view.get_librenms_device_info(obj)
+
+ assert result["found_in_librenms"] is True
+ # NetBox identities: {"sw01.example.net", "10.0.0.1"}
+ # LibreNMS identities: {"sw01.other.net", "sw01", "10.0.0.2"}
+ # No overlap β mismatch
+ assert result["mismatched_device"] is True
+
+ @patch("netbox_librenms_plugin.views.base.librenms_sync_view.match_librenms_hardware_to_device_type")
+ def test_no_netbox_name_no_ip_match(self, mock_hw):
+ """No NetBox name and IPs differ -- mismatch."""
+ view = _make_view(42, {"sysName": "sw01", "ip": "10.0.0.2"})
+ obj = _make_obj(None, primary_ip="10.0.0.1")
+ result = view.get_librenms_device_info(obj)
+
+ assert result["found_in_librenms"] is True
+ assert result["mismatched_device"] is True
+
+ @patch("netbox_librenms_plugin.views.base.librenms_sync_view.match_librenms_hardware_to_device_type")
+ def test_no_librenms_sysname_no_match(self, mock_hw):
+ """No sysName, no hostname, IPs differ -- mismatch."""
+ view = _make_view(42, {"sysName": None, "ip": "10.0.0.2"})
+ obj = _make_obj("sw01", primary_ip="10.0.0.1")
+ result = view.get_librenms_device_info(obj)
+
+ assert result["found_in_librenms"] is True
+ assert result["mismatched_device"] is True
+
+ @patch("netbox_librenms_plugin.views.base.librenms_sync_view.match_librenms_hardware_to_device_type")
+ def test_no_identities_at_all(self, mock_hw):
+ """Both sides have no identities -- mismatch (cannot confirm)."""
+ view = _make_view(42, {"sysName": None, "ip": None})
+ obj = _make_obj(None, primary_ip=None)
+ result = view.get_librenms_device_info(obj)
+
+ assert result["found_in_librenms"] is True
+ assert result["mismatched_device"] is True
+
+ # -- Virtual Chassis ---------------------------------------------------
+
+ @patch("netbox_librenms_plugin.views.base.librenms_sync_view.match_librenms_hardware_to_device_type")
+ def test_vc_suffix_stripped(self, mock_hw):
+ """VC member suffix ' (1)' is stripped before comparison."""
+ view = _make_view(42, {"sysName": "switch-1", "ip": "10.0.0.2"})
+ obj = _make_obj("switch-1 (1)", primary_ip="10.0.0.1")
+ result = view.get_librenms_device_info(obj)
+
+ assert result["found_in_librenms"] is True
+ assert result["mismatched_device"] is False
+
+ @patch("netbox_librenms_plugin.views.base.librenms_sync_view.match_librenms_hardware_to_device_type")
+ def test_vc_different_name_is_mismatch(self, mock_hw):
+ """VC member with different name after suffix strip -- mismatch."""
+ vc = MagicMock()
+ view = _make_view(42, {"sysName": "switch-1", "ip": "10.0.0.2"})
+ obj = _make_obj("switch-2 (2)", primary_ip="10.0.0.1", virtual_chassis=vc, cf={"librenms_id": 42})
+ result = view.get_librenms_device_info(obj)
+
+ assert result["found_in_librenms"] is True
+ assert result["mismatched_device"] is True
+
+ # -- found_in_librenms always True with valid ID -----------------------
+
+ @patch("netbox_librenms_plugin.views.base.librenms_sync_view.match_librenms_hardware_to_device_type")
+ def test_found_in_librenms_always_true_with_valid_id(self, mock_hw):
+ """found_in_librenms is True even when identities mismatch."""
+ view = _make_view(42, {"sysName": "totally-different", "ip": "10.0.0.2"})
+ obj = _make_obj("my-device", primary_ip="10.0.0.1")
+ result = view.get_librenms_device_info(obj)
+
+ assert result["found_in_librenms"] is True
+
+ # -- Domain stripping --------------------------------------------------
+
+ @patch("netbox_librenms_plugin.views.base.librenms_sync_view.match_librenms_hardware_to_device_type")
+ def test_domain_strip_hostname(self, mock_hw):
+ """LibreNMS hostname FQDN stripped to short name matches NetBox name."""
+ view = _make_view(42, {"sysName": "other", "hostname": "sw01.example.net", "ip": "10.0.0.2"})
+ obj = _make_obj("sw01", primary_ip="10.0.0.1")
+ result = view.get_librenms_device_info(obj)
+
+ assert result["mismatched_device"] is False
+
+ @patch("netbox_librenms_plugin.views.base.librenms_sync_view.match_librenms_hardware_to_device_type")
+ def test_domain_strip_sysname(self, mock_hw):
+ """LibreNMS sysName FQDN stripped to short name matches NetBox name."""
+ view = _make_view(42, {"sysName": "sw01.corp.local", "ip": "10.0.0.2"})
+ obj = _make_obj("sw01", primary_ip="10.0.0.1")
+ result = view.get_librenms_device_info(obj)
+
+ assert result["mismatched_device"] is False
+
+ @patch("netbox_librenms_plugin.views.base.librenms_sync_view.match_librenms_hardware_to_device_type")
+ def test_domain_strip_no_false_positive(self, mock_hw):
+ """Domain stripping doesn't cause false match when short names differ."""
+ view = _make_view(42, {"sysName": "router01.example.net", "ip": "10.0.0.2"})
+ obj = _make_obj("switch01", primary_ip="10.0.0.1")
+ result = view.get_librenms_device_info(obj)
+
+ assert result["mismatched_device"] is True
+
+ # -- VC pattern stripping ----------------------------------------------
+
+ @patch("netbox_librenms_plugin.views.base.librenms_sync_view.match_librenms_hardware_to_device_type")
+ @patch("netbox_librenms_plugin.models.LibreNMSSettings.objects")
+ def test_vc_pattern_strip_default(self, mock_settings_qs, mock_hw):
+ """Default VC pattern '-M{position}' is stripped from NetBox name."""
+ settings_obj = MagicMock()
+ settings_obj.vc_member_name_pattern = "-M{position}"
+ mock_settings_qs.first.return_value = settings_obj
+
+ view = _make_view(42, {"sysName": "switch01", "ip": "10.0.0.2"})
+ obj = _make_obj("switch01-M2", primary_ip="10.0.0.1")
+ result = view.get_librenms_device_info(obj)
+
+ assert result["mismatched_device"] is False
+
+ @patch("netbox_librenms_plugin.views.base.librenms_sync_view.match_librenms_hardware_to_device_type")
+ @patch("netbox_librenms_plugin.models.LibreNMSSettings.objects")
+ def test_vc_pattern_strip_custom(self, mock_settings_qs, mock_hw):
+ """Custom VC pattern '-SW{position}' is stripped from NetBox name."""
+ settings_obj = MagicMock()
+ settings_obj.vc_member_name_pattern = "-SW{position}"
+ mock_settings_qs.first.return_value = settings_obj
+
+ view = _make_view(42, {"sysName": "switch01", "ip": "10.0.0.2"})
+ obj = _make_obj("switch01-SW3", primary_ip="10.0.0.1")
+ result = view.get_librenms_device_info(obj)
+
+ assert result["mismatched_device"] is False
+
+ @patch("netbox_librenms_plugin.views.base.librenms_sync_view.match_librenms_hardware_to_device_type")
+ @patch("netbox_librenms_plugin.models.LibreNMSSettings.objects")
+ def test_vc_pattern_no_match_leaves_name(self, mock_settings_qs, mock_hw):
+ """VC pattern doesn't match -- name unchanged, still mismatched."""
+ settings_obj = MagicMock()
+ settings_obj.vc_member_name_pattern = "-M{position}"
+ mock_settings_qs.first.return_value = settings_obj
+
+ view = _make_view(42, {"sysName": "switch01", "ip": "10.0.0.2"})
+ obj = _make_obj("switch99", primary_ip="10.0.0.1")
+ result = view.get_librenms_device_info(obj)
+
+ assert result["mismatched_device"] is True
diff --git a/netbox_librenms_plugin/tests/test_utils.py b/netbox_librenms_plugin/tests/test_utils.py
index cca259d18a..96065ab760 100644
--- a/netbox_librenms_plugin/tests/test_utils.py
+++ b/netbox_librenms_plugin/tests/test_utils.py
@@ -4,6 +4,7 @@
platform matching, and conversion helper functions.
"""
+import json
from unittest.mock import MagicMock, patch
# =============================================================================
@@ -427,8 +428,140 @@ def test_get_interface_name_field_from_config(self, mock_plugin_config):
mock_request = MagicMock()
mock_request.GET = {}
mock_request.POST = {}
+ mock_request.user.config.get.return_value = None
result = get_interface_name_field(mock_request)
assert result == "ifAlias"
mock_plugin_config.assert_called_with("netbox_librenms_plugin", "interface_name_field")
+
+ @patch("netbox_librenms_plugin.utils.get_plugin_config")
+ def test_get_interface_name_field_from_user_pref(self, mock_plugin_config):
+ """Falls back to user preference before plugin config."""
+ from netbox_librenms_plugin.utils import get_interface_name_field
+
+ mock_request = MagicMock()
+ mock_request.GET = {}
+ mock_request.POST = {}
+ mock_request.user.config.get.return_value = "ifName"
+
+ result = get_interface_name_field(mock_request)
+
+ assert result == "ifName"
+ mock_plugin_config.assert_not_called()
+
+ @patch("netbox_librenms_plugin.utils.get_plugin_config")
+ def test_get_interface_name_field_persists_to_user_pref(self, mock_plugin_config):
+ """Explicit GET param should be persisted to user preferences."""
+ from netbox_librenms_plugin.utils import get_interface_name_field
+
+ mock_request = MagicMock()
+ mock_request.GET = {"interface_name_field": "ifDescr"}
+ mock_request.POST = {}
+
+ result = get_interface_name_field(mock_request)
+
+ assert result == "ifDescr"
+ mock_request.user.config.set.assert_called_once_with(
+ "plugins.netbox_librenms_plugin.interface_name_field", "ifDescr", commit=True
+ )
+
+
+# =============================================================================
+# TestSaveUserPrefView - 6 tests
+# =============================================================================
+
+
+class TestSaveUserPrefView:
+ """Test SaveUserPrefView endpoint for JS-driven preference persistence."""
+
+ def _make_request(self, body, has_perm=True):
+ """Create a mock POST request with JSON body."""
+ request = MagicMock()
+ request.body = json.dumps(body).encode()
+ request.user.has_perm.return_value = has_perm
+ request.user.config = MagicMock()
+ request.method = "POST"
+ return request
+
+ def test_save_valid_boolean_pref(self):
+ """Saving a valid boolean preference returns ok."""
+ from netbox_librenms_plugin.views.imports.actions import SaveUserPrefView
+
+ view = SaveUserPrefView()
+ request = self._make_request({"key": "use_sysname", "value": True})
+ view.request = request
+
+ response = view.post(request)
+
+ assert response.status_code == 200
+ data = json.loads(response.content)
+ assert data["status"] == "ok"
+ request.user.config.set.assert_called_once_with("plugins.netbox_librenms_plugin.use_sysname", True, commit=True)
+
+ def test_save_string_pref(self):
+ """Saving interface_name_field string value works."""
+ from netbox_librenms_plugin.views.imports.actions import SaveUserPrefView
+
+ view = SaveUserPrefView()
+ request = self._make_request({"key": "interface_name_field", "value": "ifDescr"})
+ view.request = request
+
+ response = view.post(request)
+
+ assert response.status_code == 200
+ request.user.config.set.assert_called_once_with(
+ "plugins.netbox_librenms_plugin.interface_name_field", "ifDescr", commit=True
+ )
+
+ def test_reject_invalid_key(self):
+ """Invalid preference key returns 400."""
+ from netbox_librenms_plugin.views.imports.actions import SaveUserPrefView
+
+ view = SaveUserPrefView()
+ request = self._make_request({"key": "malicious_key", "value": True})
+ view.request = request
+
+ response = view.post(request)
+
+ assert response.status_code == 400
+ data = json.loads(response.content)
+ assert "Invalid preference key" in data["error"]
+ request.user.config.set.assert_not_called()
+
+ def test_reject_invalid_json(self):
+ """Invalid JSON body returns 400."""
+ from netbox_librenms_plugin.views.imports.actions import SaveUserPrefView
+
+ view = SaveUserPrefView()
+ request = MagicMock()
+ request.body = b"not valid json"
+ view.request = request
+
+ response = view.post(request)
+
+ assert response.status_code == 400
+ data = json.loads(response.content)
+ assert "Invalid JSON" in data["error"]
+
+ def test_save_false_value(self):
+ """Saving False for a toggle works correctly."""
+ from netbox_librenms_plugin.views.imports.actions import SaveUserPrefView
+
+ view = SaveUserPrefView()
+ request = self._make_request({"key": "strip_domain", "value": False})
+ view.request = request
+
+ response = view.post(request)
+
+ assert response.status_code == 200
+ request.user.config.set.assert_called_once_with(
+ "plugins.netbox_librenms_plugin.strip_domain", False, commit=True
+ )
+
+ def test_uses_permission_mixin(self):
+ """SaveUserPrefView inherits from LibreNMSPermissionMixin."""
+ from netbox_librenms_plugin.views.imports.actions import SaveUserPrefView
+ from netbox_librenms_plugin.views.mixins import LibreNMSPermissionMixin
+
+ assert issubclass(SaveUserPrefView, LibreNMSPermissionMixin)
diff --git a/netbox_librenms_plugin/tests/test_vlan_sync.py b/netbox_librenms_plugin/tests/test_vlan_sync.py
new file mode 100644
index 0000000000..caaced6f91
--- /dev/null
+++ b/netbox_librenms_plugin/tests/test_vlan_sync.py
@@ -0,0 +1,461 @@
+"""
+Tests for VLAN sync feature.
+
+Tests cover:
+- LibreNMS VLAN API methods
+- VLAN mode detection logic
+- VLAN comparison logic
+- Port VLAN data parsing
+"""
+
+from unittest.mock import MagicMock, patch
+
+# Import the autouse fixture from helpers
+pytest_plugins = ["netbox_librenms_plugin.tests.test_librenms_api_helpers"]
+
+
+# ============================================
+# TEST DATA FIXTURES
+# ============================================
+
+# Sample LibreNMS VLAN response (from /resources/vlans endpoint)
+# Note: This endpoint includes vlan_id and device_id, unlike /devices/{id}/vlans
+MOCK_DEVICE_VLANS = {
+ "status": "ok",
+ "vlans": [
+ {
+ "vlan_id": 101,
+ "device_id": 123,
+ "vlan_vlan": 1,
+ "vlan_name": "default",
+ "vlan_type": "ethernet",
+ "vlan_state": 1,
+ "vlan_domain": 1,
+ },
+ {
+ "vlan_id": 102,
+ "device_id": 123,
+ "vlan_vlan": 50,
+ "vlan_name": "ORG_DATA",
+ "vlan_type": "ethernet",
+ "vlan_state": 1,
+ "vlan_domain": 1,
+ },
+ {
+ "vlan_id": 103,
+ "device_id": 123,
+ "vlan_vlan": 60,
+ "vlan_name": "ORG_VOICE",
+ "vlan_type": "ethernet",
+ "vlan_state": 1,
+ "vlan_domain": 1,
+ },
+ ],
+ "count": 3,
+}
+
+# Sample port VLAN info response (bulk call)
+MOCK_PORT_VLAN_INFO = {
+ "status": "ok",
+ "ports": [
+ {"port_id": 114184, "ifName": "Gi1/0/40", "ifVlan": "50", "ifTrunk": None},
+ {"port_id": 114326, "ifName": "Gi3/0/48", "ifVlan": "1", "ifTrunk": "dot1Q"},
+ {"port_id": 114327, "ifName": "Gi3/1/1", "ifVlan": "1", "ifTrunk": None},
+ {"port_id": 114145, "ifName": "Gi1/0/1", "ifVlan": "", "ifTrunk": None}, # No VLAN
+ ],
+}
+
+# Sample port with vlans detail response (for trunk port)
+MOCK_PORT_VLAN_DETAILS_TRUNK = {
+ "status": "ok",
+ "port": [
+ {
+ "port_id": 227011,
+ "ifName": "Te1/1/1",
+ "ifVlan": "90",
+ "ifTrunk": "dot1Q",
+ "vlans": [
+ {"vlan": 90, "untagged": 1, "state": "unknown", "port_vlan_id": 195164},
+ {"vlan": 50, "untagged": 0, "state": "forwarding", "port_vlan_id": 2165422},
+ ],
+ }
+ ],
+}
+
+# Sample port with vlans detail response (for access port)
+MOCK_PORT_VLAN_DETAILS_ACCESS = {
+ "status": "ok",
+ "port": [
+ {
+ "port_id": 729403,
+ "ifName": "Gi0/2",
+ "ifVlan": "50",
+ "ifTrunk": None,
+ "vlans": [
+ {"vlan": 50, "untagged": 1, "state": "forwarding", "port_vlan_id": 3234550},
+ ],
+ }
+ ],
+}
+
+
+def create_mock_device():
+ """Create a mock NetBox device."""
+ device = MagicMock()
+ device.pk = 123
+ device.name = "test-switch"
+ device._meta.model_name = "device"
+ device.site = MagicMock()
+ device.site.pk = 1
+ device.site.name = "Test Site"
+ return device
+
+
+def create_mock_interface(name, mode=None, untagged_vlan=None, tagged_vlans=None):
+ """Create a mock NetBox interface."""
+ interface = MagicMock()
+ interface.pk = hash(name)
+ interface.name = name
+ interface.mode = mode
+ interface.untagged_vlan = untagged_vlan
+ interface.tagged_vlans = MagicMock()
+ interface.tagged_vlans.all.return_value = tagged_vlans or []
+ return interface
+
+
+def create_mock_vlan(vid, name, group=None):
+ """Create a mock NetBox VLAN."""
+ vlan = MagicMock()
+ vlan.pk = vid * 100
+ vlan.vid = vid
+ vlan.name = name
+ vlan.group = group
+ return vlan
+
+
+# ============================================
+# API METHOD TESTS
+# ============================================
+
+
+class TestVLANAPIClient:
+ """Tests for LibreNMS VLAN API methods."""
+
+ @patch("requests.get")
+ def test_get_device_vlans_success(self, mock_get, mock_librenms_config):
+ """Test successful VLAN fetch from /resources/vlans endpoint."""
+ mock_get.return_value.status_code = 200
+ mock_get.return_value.json.return_value = MOCK_DEVICE_VLANS
+
+ from netbox_librenms_plugin.librenms_api import LibreNMSAPI
+
+ api = LibreNMSAPI(server_key="default")
+
+ success, data = api.get_device_vlans(123)
+
+ assert success is True
+ assert len(data) == 3
+ assert data[1]["vlan_vlan"] == 50
+ assert data[1]["vlan_name"] == "ORG_DATA"
+ # Verify vlan_id is present from /resources/vlans endpoint
+ assert data[1]["vlan_id"] == 102
+
+ @patch("requests.get")
+ def test_get_device_vlans_filters_by_device_id(self, mock_get, mock_librenms_config):
+ """Test that VLANs are filtered by device_id."""
+ # Response includes VLANs from multiple devices
+ mock_response_data = {
+ "status": "ok",
+ "vlans": [
+ {"vlan_id": 101, "device_id": 123, "vlan_vlan": 1, "vlan_name": "default"},
+ {"vlan_id": 201, "device_id": 456, "vlan_vlan": 1, "vlan_name": "default"}, # Different device
+ {"vlan_id": 102, "device_id": 123, "vlan_vlan": 50, "vlan_name": "DATA"},
+ ],
+ }
+ mock_get.return_value.status_code = 200
+ mock_get.return_value.json.return_value = mock_response_data
+
+ from netbox_librenms_plugin.librenms_api import LibreNMSAPI
+
+ api = LibreNMSAPI(server_key="default")
+ success, data = api.get_device_vlans(123)
+
+ assert success is True
+ assert len(data) == 2 # Only device 123's VLANs
+ assert all(str(v["device_id"]) == "123" for v in data)
+
+ @patch("requests.get")
+ def test_get_device_vlans_error(self, mock_get, mock_librenms_config):
+ """Test VLAN fetch with error."""
+ from requests.exceptions import HTTPError
+
+ mock_response = MagicMock()
+ mock_response.status_code = 404
+ mock_response.raise_for_status.side_effect = HTTPError(response=mock_response)
+ mock_get.return_value = mock_response
+
+ from netbox_librenms_plugin.librenms_api import LibreNMSAPI
+
+ api = LibreNMSAPI(server_key="default")
+
+ success, data = api.get_device_vlans(999)
+
+ assert success is False
+ assert "not found" in data.lower()
+
+ @patch("requests.get")
+ def test_get_port_vlan_details_trunk(self, mock_get, mock_librenms_config):
+ """Test fetching trunk port VLAN details."""
+ mock_get.return_value.status_code = 200
+ mock_get.return_value.json.return_value = MOCK_PORT_VLAN_DETAILS_TRUNK
+
+ from netbox_librenms_plugin.librenms_api import LibreNMSAPI
+
+ api = LibreNMSAPI(server_key="default")
+
+ success, data = api.get_port_vlan_details(227011)
+
+ assert success is True
+ assert data["ifTrunk"] == "dot1Q"
+ assert len(data["vlans"]) == 2
+
+ @patch("requests.get")
+ def test_get_port_vlan_details_not_found(self, mock_get, mock_librenms_config):
+ """Test fetching port details when port not found."""
+ mock_get.return_value.status_code = 200
+ mock_get.return_value.json.return_value = {"status": "ok", "port": []}
+
+ from netbox_librenms_plugin.librenms_api import LibreNMSAPI
+
+ api = LibreNMSAPI(server_key="default")
+
+ success, data = api.get_port_vlan_details(999999)
+
+ assert success is False
+ assert "not found" in data.lower()
+
+
+# ============================================
+# MODE DETECTION TESTS
+# ============================================
+
+
+class TestVLANModeDetection:
+ """Tests for 802.1Q mode detection logic."""
+
+ def test_parse_port_vlan_data_access_port(self, mock_librenms_config):
+ """Access port: ifVlan set, ifTrunk null."""
+ from netbox_librenms_plugin.librenms_api import LibreNMSAPI
+
+ api = LibreNMSAPI(server_key="default")
+
+ port_data = {"port_id": 1, "ifName": "Gi1/0/1", "ifVlan": "50", "ifTrunk": None}
+ result = api.parse_port_vlan_data(port_data)
+
+ assert result["mode"] == "access"
+ assert result["untagged_vlan"] == 50
+ assert result["tagged_vlans"] == []
+
+ def test_parse_port_vlan_data_trunk_port(self, mock_librenms_config):
+ """Trunk port: ifTrunk = dot1Q."""
+ from netbox_librenms_plugin.librenms_api import LibreNMSAPI
+
+ api = LibreNMSAPI(server_key="default")
+
+ port_data = {
+ "port_id": 2,
+ "ifName": "Te1/1/1",
+ "ifVlan": "90",
+ "ifTrunk": "dot1Q",
+ "vlans": [
+ {"vlan": 90, "untagged": 1},
+ {"vlan": 50, "untagged": 0},
+ {"vlan": 60, "untagged": 0},
+ ],
+ }
+ result = api.parse_port_vlan_data(port_data)
+
+ assert result["mode"] == "tagged"
+ assert result["untagged_vlan"] == 90
+ assert result["tagged_vlans"] == [50, 60]
+
+ def test_parse_port_vlan_data_no_vlan(self, mock_librenms_config):
+ """No VLAN: ifVlan empty."""
+ from netbox_librenms_plugin.librenms_api import LibreNMSAPI
+
+ api = LibreNMSAPI(server_key="default")
+
+ port_data = {"port_id": 3, "ifName": "Gi1/0/48", "ifVlan": "", "ifTrunk": None}
+ result = api.parse_port_vlan_data(port_data)
+
+ assert result["mode"] is None
+ assert result["untagged_vlan"] is None
+ assert result["tagged_vlans"] == []
+
+
+# ============================================
+# VLAN COMPARISON TESTS
+# ============================================
+
+
+class TestVLANComparison:
+ """Tests for VLAN comparison logic."""
+
+ def test_compare_vlans_exists_in_netbox(self):
+ """Test VLAN exists in NetBox VLAN group."""
+ netbox_vlans = {50: create_mock_vlan(50, "ORG_DATA")}
+ librenms_vlan = {"vlan_vlan": 50, "vlan_name": "ORG_DATA"}
+
+ exists = librenms_vlan["vlan_vlan"] in netbox_vlans
+ assert exists is True
+
+ def test_compare_vlans_missing_from_netbox(self):
+ """Test VLAN missing from NetBox."""
+ netbox_vlans = {50: create_mock_vlan(50, "ORG_DATA")}
+ librenms_vlan = {"vlan_vlan": 60, "vlan_name": "ORG_VOICE"}
+
+ exists = librenms_vlan["vlan_vlan"] in netbox_vlans
+ assert exists is False
+
+ def test_compare_vlans_name_matches(self):
+ """Test VLAN name comparison when matching."""
+ netbox_vlan = create_mock_vlan(50, "ORG_DATA")
+ librenms_name = "ORG_DATA"
+
+ name_matches = netbox_vlan.name == librenms_name
+ assert name_matches is True
+
+ def test_compare_vlans_name_differs(self):
+ """Test VLAN name comparison when different."""
+ netbox_vlan = create_mock_vlan(50, "DATA_VLAN")
+ librenms_name = "ORG_DATA"
+
+ name_matches = netbox_vlan.name == librenms_name
+ assert name_matches is False
+
+
+# ============================================
+# PORT VLAN PARSING TESTS
+# ============================================
+
+
+class TestPortVLANParsing:
+ """Tests for parsing port VLAN data."""
+
+ def test_parse_trunk_port_vlans(self):
+ """Parse trunk port into untagged and tagged lists."""
+ vlans_data = MOCK_PORT_VLAN_DETAILS_TRUNK["port"][0]["vlans"]
+
+ untagged = [v["vlan"] for v in vlans_data if v["untagged"] == 1]
+ tagged = [v["vlan"] for v in vlans_data if v["untagged"] == 0]
+
+ assert untagged == [90]
+ assert tagged == [50]
+
+ def test_parse_access_port_vlans(self):
+ """Parse access port - single untagged VLAN."""
+ vlans_data = MOCK_PORT_VLAN_DETAILS_ACCESS["port"][0]["vlans"]
+
+ untagged = [v["vlan"] for v in vlans_data if v["untagged"] == 1]
+ tagged = [v["vlan"] for v in vlans_data if v["untagged"] == 0]
+
+ assert untagged == [50]
+ assert tagged == []
+
+ def test_parse_port_with_multiple_tagged(self):
+ """Parse trunk port with multiple tagged VLANs."""
+ vlans_data = [
+ {"vlan": 1, "untagged": 1},
+ {"vlan": 10, "untagged": 0},
+ {"vlan": 20, "untagged": 0},
+ {"vlan": 30, "untagged": 0},
+ ]
+
+ untagged = [v["vlan"] for v in vlans_data if v["untagged"] == 1]
+ tagged = [v["vlan"] for v in vlans_data if v["untagged"] == 0]
+
+ assert untagged == [1]
+ assert len(tagged) == 3
+ assert set(tagged) == {10, 20, 30}
+
+
+# ============================================
+# SYNC ACTION TESTS
+# ============================================
+
+
+class TestSyncVLANActions:
+ """Tests for VLAN sync action logic."""
+
+ def test_mode_mapping_access(self):
+ """Test mapping LibreNMS access mode to NetBox."""
+ librenms_mode = "access"
+ expected_netbox_mode = "access"
+
+ mode_map = {"access": "access", "tagged": "tagged"}
+ result = mode_map.get(librenms_mode)
+
+ assert result == expected_netbox_mode
+
+ def test_mode_mapping_tagged(self):
+ """Test mapping LibreNMS tagged mode to NetBox."""
+ librenms_mode = "tagged"
+ expected_netbox_mode = "tagged"
+
+ mode_map = {"access": "access", "tagged": "tagged"}
+ result = mode_map.get(librenms_mode)
+
+ assert result == expected_netbox_mode
+
+ def test_vlan_state_mapping_active(self):
+ """Test mapping active VLAN state."""
+ vlan_state = 1
+
+ status = "active" if vlan_state == 1 else "reserved"
+ assert status == "active"
+
+ def test_vlan_state_mapping_inactive(self):
+ """Test mapping inactive VLAN state."""
+ vlan_state = 0
+
+ status = "active" if vlan_state == 1 else "reserved"
+ assert status == "reserved"
+
+
+# ============================================
+# VLAN SYNC CSS CLASS UTILITY
+# ============================================
+
+
+class TestGetVlanSyncCssClass:
+ """Tests for the shared get_vlan_sync_css_class utility."""
+
+ def test_not_in_netbox(self):
+ """VLAN not in NetBox should return text-danger."""
+ from netbox_librenms_plugin.utils import get_vlan_sync_css_class
+
+ assert get_vlan_sync_css_class(exists_in_netbox=False) == "text-danger"
+
+ def test_not_in_netbox_name_match_irrelevant(self):
+ """Name match flag should be irrelevant when VLAN doesn't exist."""
+ from netbox_librenms_plugin.utils import get_vlan_sync_css_class
+
+ assert get_vlan_sync_css_class(exists_in_netbox=False, name_matches=True) == "text-danger"
+
+ def test_exists_name_matches(self):
+ """VLAN exists with matching name should return text-success."""
+ from netbox_librenms_plugin.utils import get_vlan_sync_css_class
+
+ assert get_vlan_sync_css_class(exists_in_netbox=True, name_matches=True) == "text-success"
+
+ def test_exists_name_mismatch(self):
+ """VLAN exists but name differs should return text-warning."""
+ from netbox_librenms_plugin.utils import get_vlan_sync_css_class
+
+ assert get_vlan_sync_css_class(exists_in_netbox=True, name_matches=False) == "text-warning"
+
+ def test_default_name_matches_is_true(self):
+ """Default name_matches should be True (success when exists)."""
+ from netbox_librenms_plugin.utils import get_vlan_sync_css_class
+
+ assert get_vlan_sync_css_class(exists_in_netbox=True) == "text-success"
diff --git a/netbox_librenms_plugin/urls.py b/netbox_librenms_plugin/urls.py
index 5660b3eb88..af12187c6f 100644
--- a/netbox_librenms_plugin/urls.py
+++ b/netbox_librenms_plugin/urls.py
@@ -18,6 +18,7 @@
DeviceStatusListView,
DeviceValidationDetailsView,
DeviceVCDetailsView,
+ DeviceVLANTableView,
InterfaceTypeMappingBulkDeleteView,
InterfaceTypeMappingBulkImportView,
InterfaceTypeMappingChangeLogView,
@@ -28,13 +29,18 @@
InterfaceTypeMappingView,
LibreNMSImportView,
LibreNMSSettingsView,
+ SaveUserPrefView,
SingleCableVerifyView,
SingleInterfaceVerifyView,
SingleIPAddressVerifyView,
+ SaveVlanGroupOverridesView,
+ SingleVlanGroupVerifyView,
+ VerifyVlanSyncGroupView,
SyncCablesView,
SyncInterfacesView,
SyncIPAddressesView,
SyncSiteLocationView,
+ SyncVLANsView,
TestLibreNMSConnectionView,
UpdateDeviceLocationView,
UpdateDevicePlatformView,
@@ -85,6 +91,24 @@
SingleIPAddressVerifyView.as_view(),
name="verify_ipaddress",
),
+ # Path for VLAN group verify javascript call (interface VLAN coloring)
+ path(
+ "verify-vlan-group/",
+ SingleVlanGroupVerifyView.as_view(),
+ name="verify_vlan_group",
+ ),
+ # Verify VLAN existence in a group (VLAN sync tab coloring)
+ path(
+ "verify-vlan-sync-group/",
+ VerifyVlanSyncGroupView.as_view(),
+ name="verify_vlan_sync_group",
+ ),
+ # Save VLAN group overrides to cache ("apply to all" persistence)
+ path(
+ "save-vlan-group-overrides/",
+ SaveVlanGroupOverridesView.as_view(),
+ name="save_vlan_group_overrides",
+ ),
# Virtual machine sync URLs
path(
"virtual-machines/
/interface-sync/",
@@ -125,6 +149,17 @@
SyncIPAddressesView.as_view(),
name="sync_device_ip_addresses",
),
+ # VLAN sync URLs
+ path(
+ "devices//vlan-sync/",
+ DeviceVLANTableView.as_view(),
+ name="device_vlan_sync",
+ ),
+ path(
+ "//sync-vlans/",
+ SyncVLANsView.as_view(),
+ name="sync_selected_vlans",
+ ),
# Add Device to LibreNMS URLs
path(
"add-device//",
@@ -224,6 +259,11 @@
DeviceRackUpdateView.as_view(),
name="device_rack_update",
),
+ path(
+ "save-user-pref/",
+ SaveUserPrefView.as_view(),
+ name="save_user_pref",
+ ),
path(
"vm-status/",
VMStatusListView.as_view(),
diff --git a/netbox_librenms_plugin/utils.py b/netbox_librenms_plugin/utils.py
index cb421f19fa..4a5bf113a4 100644
--- a/netbox_librenms_plugin/utils.py
+++ b/netbox_librenms_plugin/utils.py
@@ -142,10 +142,29 @@ def get_table_paginate_count(request: HttpRequest, table_prefix: str) -> int:
return netbox_get_paginate_count(request)
+def get_user_pref(request, path, default=None):
+ """Get a user preference value via request.user.config."""
+ if hasattr(request, "user") and hasattr(request.user, "config"):
+ return request.user.config.get(path, default)
+ return default
+
+
+def save_user_pref(request, path, value):
+ """Save a user preference value via request.user.config."""
+ if hasattr(request, "user") and hasattr(request.user, "config"):
+ try:
+ request.user.config.set(path, value, commit=True)
+ except (TypeError, ValueError):
+ pass
+
+
def get_interface_name_field(request: Optional[HttpRequest] = None) -> str:
"""
Get interface name field with request override support.
+ Checks in order: GET/POST params, user preference, plugin config default.
+ When a param is explicitly provided, persists it to user preferences.
+
Args:
request: Optional HTTP request object that may contain override
@@ -153,10 +172,18 @@ def get_interface_name_field(request: Optional[HttpRequest] = None) -> str:
str: Interface name field to use
"""
if request:
- if request.GET.get("interface_name_field"):
- return request.GET.get("interface_name_field")
- if request.POST.get("interface_name_field"):
- return request.POST.get("interface_name_field")
+ # Explicit override from request params
+ param_val = request.GET.get("interface_name_field") or request.POST.get("interface_name_field")
+ if param_val:
+ existing = get_user_pref(request, "plugins.netbox_librenms_plugin.interface_name_field")
+ if param_val != existing:
+ save_user_pref(request, "plugins.netbox_librenms_plugin.interface_name_field", param_val)
+ return param_val
+
+ # Check user preference
+ pref_val = get_user_pref(request, "plugins.netbox_librenms_plugin.interface_name_field")
+ if pref_val:
+ return pref_val
# Fall back to plugin config
return get_plugin_config("netbox_librenms_plugin", "interface_name_field")
@@ -278,3 +305,145 @@ def find_matching_platform(librenms_os: str) -> dict:
return {"found": True, "platform": platform, "match_type": "exact"}
return {"found": False, "platform": None, "match_type": None}
+
+
+def get_vlan_sync_css_class(exists_in_netbox: bool, name_matches: bool = True) -> str:
+ """
+ Determine CSS class for a VLAN row on the VLAN sync tab.
+
+ Used by both the server-side table renderer (LibreNMSVLANTable)
+ and the client-facing verify endpoint (VerifyVlanSyncGroupView)
+ to keep color logic consistent.
+
+ Args:
+ exists_in_netbox: Whether the VLAN exists in NetBox (in the selected group or globally).
+ name_matches: Whether the VLAN name in NetBox matches the LibreNMS name.
+
+ Returns:
+ CSS class string: 'text-success', 'text-warning', or 'text-danger'.
+ """
+ if not exists_in_netbox:
+ return "text-danger"
+ if name_matches:
+ return "text-success"
+ return "text-warning"
+
+
+# ============================================
+# Interface VLAN CSS helpers
+# ============================================
+# Shared by LibreNMSInterfaceTable (tables/interfaces.py) and
+# SingleVlanGroupVerifyView (views/object_sync/devices.py).
+
+
+def get_untagged_vlan_css_class(librenms_vid, netbox_vid, exists_in_netbox, missing_vlans, group_matches=True):
+ """
+ Get CSS class for an untagged VLAN comparison.
+
+ Color logic:
+ - Red (text-danger) + warning icon: VLAN not in any NetBox group (cannot sync)
+ - Red (text-danger): Interface missing from NetBox, or no untagged VLAN in NetBox
+ - Orange (text-warning): Different untagged VLAN assigned, or same VID but different group
+ - Green (text-success): Same untagged VLAN assigned in same group (match)
+
+ Args:
+ librenms_vid: VLAN ID from LibreNMS.
+ netbox_vid: VLAN ID currently assigned in NetBox (int or None).
+ exists_in_netbox: Whether the interface exists in NetBox.
+ missing_vlans: List of VIDs not found in any NetBox VLAN group.
+ group_matches: Whether the selected VLAN group matches the NetBox VLAN's group.
+ Only meaningful when VIDs match; defaults to True.
+
+ Returns:
+ CSS class string: text-danger, text-warning, or text-success.
+ """
+ if not exists_in_netbox:
+ return "text-danger"
+ if librenms_vid in missing_vlans:
+ return "text-danger"
+ if librenms_vid == netbox_vid:
+ if not group_matches:
+ return "text-warning"
+ return "text-success"
+ if netbox_vid is None:
+ return "text-danger"
+ return "text-warning"
+
+
+def get_tagged_vlan_css_class(vid, netbox_tagged_vids, exists_in_netbox, missing_vlans, group_matches=True):
+ """
+ Get CSS class for a tagged VLAN comparison.
+
+ Color logic:
+ - Red (text-danger) + warning icon: VLAN not in any NetBox group (cannot sync)
+ - Red (text-danger): Interface missing from NetBox, or VLAN not tagged on this interface
+ - Orange (text-warning): Same VID tagged but in different VLAN group
+ - Green (text-success): VLAN is tagged on this interface in same group
+
+ Args:
+ vid: VLAN ID to check.
+ netbox_tagged_vids: Set of VIDs currently tagged on the NetBox interface.
+ exists_in_netbox: Whether the interface exists in NetBox.
+ missing_vlans: List of VIDs not found in any NetBox VLAN group.
+ group_matches: Whether the selected VLAN group matches the NetBox VLAN's group.
+ Only meaningful when VIDs match; defaults to True.
+
+ Returns:
+ CSS class string: text-danger, text-warning, or text-success.
+ """
+ if not exists_in_netbox:
+ return "text-danger"
+ if vid in missing_vlans:
+ return "text-danger"
+ if vid in netbox_tagged_vids:
+ if not group_matches:
+ return "text-warning"
+ return "text-success"
+ return "text-danger"
+
+
+def get_missing_vlan_warning(vid, missing_vlans):
+ """Return warning icon HTML if VLAN is not found in any NetBox VLAN group."""
+ if vid in missing_vlans:
+ return (
+ ' '
+ )
+ return ""
+
+
+def check_vlan_group_matches(
+ vlan_type,
+ vid,
+ selected_group_id,
+ netbox_untagged_group_id,
+ netbox_tagged_group_ids,
+ netbox_untagged_vid,
+ netbox_tagged_vids,
+):
+ """
+ Check whether the selected VLAN group matches the NetBox VLAN's group.
+
+ Only relevant when VIDs match β if VIDs differ, the CSS is already
+ warning/danger regardless of group.
+
+ Args:
+ vlan_type: "U" or "T".
+ vid: VLAN ID.
+ selected_group_id: Group ID (int or None) the user selected.
+ netbox_untagged_group_id: group_id of netbox untagged VLAN (int or None).
+ netbox_tagged_group_ids: {vid: group_id} of netbox tagged VLANs.
+ netbox_untagged_vid: VID of netbox untagged VLAN (int or None).
+ netbox_tagged_vids: set of VIDs tagged in netbox.
+
+ Returns:
+ bool: True if groups match (or comparison not applicable).
+ """
+ if vlan_type == "U":
+ if netbox_untagged_vid == vid:
+ return netbox_untagged_group_id == selected_group_id
+ else:
+ if vid in netbox_tagged_vids:
+ netbox_gid = netbox_tagged_group_ids.get(vid)
+ return netbox_gid == selected_group_id
+ return True
diff --git a/netbox_librenms_plugin/views/__init__.py b/netbox_librenms_plugin/views/__init__.py
index a81c5459ac..d2b3bbdd43 100644
--- a/netbox_librenms_plugin/views/__init__.py
+++ b/netbox_librenms_plugin/views/__init__.py
@@ -6,6 +6,7 @@
from .base.interfaces_view import BaseInterfaceTableView
from .base.ip_addresses_view import BaseIPAddressTableView, SingleIPAddressVerifyView
from .base.librenms_sync_view import BaseLibreNMSSyncView
+from .base.vlan_table_view import BaseVLANTableView
from .imports import (
BulkImportConfirmView,
BulkImportDevicesView,
@@ -15,6 +16,7 @@
DeviceValidationDetailsView,
DeviceVCDetailsView,
LibreNMSImportView,
+ SaveUserPrefView,
)
from .mapping_views import (
InterfaceTypeMappingBulkDeleteView,
@@ -31,7 +33,11 @@
DeviceInterfaceTableView,
DeviceIPAddressTableView,
DeviceLibreNMSSyncView,
+ DeviceVLANTableView,
+ SaveVlanGroupOverridesView,
SingleInterfaceVerifyView,
+ SingleVlanGroupVerifyView,
+ VerifyVlanSyncGroupView,
VMInterfaceTableView,
VMIPAddressTableView,
VMLibreNMSSyncView,
@@ -50,3 +56,4 @@
from .sync.interfaces import DeleteNetBoxInterfacesView, SyncInterfacesView
from .sync.ip_addresses import SyncIPAddressesView
from .sync.locations import SyncSiteLocationView
+from .sync.vlans import SyncVLANsView
diff --git a/netbox_librenms_plugin/views/base/__init__.py b/netbox_librenms_plugin/views/base/__init__.py
index e69de29bb2..b354745320 100644
--- a/netbox_librenms_plugin/views/base/__init__.py
+++ b/netbox_librenms_plugin/views/base/__init__.py
@@ -0,0 +1,13 @@
+from .cables_view import BaseCableTableView
+from .interfaces_view import BaseInterfaceTableView
+from .ip_addresses_view import BaseIPAddressTableView
+from .librenms_sync_view import BaseLibreNMSSyncView
+from .vlan_table_view import BaseVLANTableView
+
+__all__ = [
+ "BaseCableTableView",
+ "BaseInterfaceTableView",
+ "BaseIPAddressTableView",
+ "BaseLibreNMSSyncView",
+ "BaseVLANTableView",
+]
diff --git a/netbox_librenms_plugin/views/base/cables_view.py b/netbox_librenms_plugin/views/base/cables_view.py
index 0322b6facb..c390cdd539 100644
--- a/netbox_librenms_plugin/views/base/cables_view.py
+++ b/netbox_librenms_plugin/views/base/cables_view.py
@@ -14,10 +14,10 @@
get_interface_name_field,
get_virtual_chassis_member,
)
-from netbox_librenms_plugin.views.mixins import CacheMixin, LibreNMSAPIMixin
+from netbox_librenms_plugin.views.mixins import CacheMixin, LibreNMSAPIMixin, LibreNMSPermissionMixin
-class BaseCableTableView(LibreNMSAPIMixin, CacheMixin, View):
+class BaseCableTableView(LibreNMSPermissionMixin, LibreNMSAPIMixin, CacheMixin, View):
"""
Base view for synchronizing cable information from LibreNMS.
"""
diff --git a/netbox_librenms_plugin/views/base/interfaces_view.py b/netbox_librenms_plugin/views/base/interfaces_view.py
index 5cca0bfe43..80e413a8f3 100644
--- a/netbox_librenms_plugin/views/base/interfaces_view.py
+++ b/netbox_librenms_plugin/views/base/interfaces_view.py
@@ -8,12 +8,18 @@
get_interface_name_field,
get_virtual_chassis_member,
)
-from netbox_librenms_plugin.views.mixins import CacheMixin, LibreNMSAPIMixin
+from netbox_librenms_plugin.views.mixins import (
+ CacheMixin,
+ LibreNMSAPIMixin,
+ LibreNMSPermissionMixin,
+ VlanAssignmentMixin,
+)
-class BaseInterfaceTableView(LibreNMSAPIMixin, CacheMixin, View):
+class BaseInterfaceTableView(VlanAssignmentMixin, LibreNMSAPIMixin, LibreNMSPermissionMixin, CacheMixin, View):
"""
Base view for fetching interface data from LibreNMS and generating table data.
+ Includes VLAN enrichment for interface VLAN sync functionality.
"""
model = None # To be defined in subclasses
@@ -50,10 +56,16 @@ def get_select_related_field(self, obj):
return "virtual_machine"
return "device"
- def get_table(self, data, obj, interface_name_field):
+ def get_table(self, data, obj, interface_name_field, vlan_groups=None):
"""
Returns the table class to use for rendering interface data.
Can be overridden by subclasses to use different tables.
+
+ Args:
+ data: List of port data dicts
+ obj: Device or VirtualMachine object
+ interface_name_field: Field to use for interface name ('ifName' or 'ifDescr')
+ vlan_groups: List of VLANGroup objects for VLAN group dropdowns
"""
raise NotImplementedError("Subclasses must implement get_table()")
@@ -76,6 +88,11 @@ def post(self, request, pk):
messages.error(request, librenms_data)
return redirect(self.get_redirect_url(obj))
+ # Enrich ports with VLAN data for trunk ports
+ ports = librenms_data.get("ports", [])
+ enriched_ports = self._enrich_ports_with_vlan_data(ports, interface_name_field)
+ librenms_data["ports"] = enriched_ports
+
# Store data in cache
cache.set(
self.get_cache_key(obj, "ports"),
@@ -97,6 +114,30 @@ def post(self, request, pk):
return render(request, self.partial_template_name, context)
+ def _enrich_ports_with_vlan_data(self, ports, interface_name_field):
+ """
+ Enrich port data with VLAN information from LibreNMS.
+
+ With LibreNMS 24.2.0+, the get_ports() call with with_vlans=True returns
+ detailed VLAN associations (tagged/untagged) for all ports. The
+ parse_port_vlan_data() method handles both the new vlans array format
+ and falls back to ifVlan for older LibreNMS versions.
+
+ Args:
+ ports: List of port dicts from get_ports(with_vlans=True)
+ interface_name_field: Field to use for interface name
+
+ Returns:
+ List of enriched port dicts with VLAN data
+ """
+ enriched = []
+ for port in ports:
+ # Parse VLAN data - handles both vlans array (new) and ifVlan fallback (old)
+ parsed = self.librenms_api.parse_port_vlan_data(port, interface_name_field)
+ port.update(parsed)
+ enriched.append(port)
+ return enriched
+
def get_context_data(self, request, obj, interface_name_field):
"""Get the context data for the interface sync view."""
ports_data = []
@@ -107,7 +148,14 @@ def get_context_data(self, request, obj, interface_name_field):
interface_name_field = get_interface_name_field(request)
cached_data = cache.get(self.get_cache_key(obj, "ports"))
- last_fetched = cache.get(self.get_last_fetched_key(obj), "ports")
+ last_fetched = cache.get(self.get_last_fetched_key(obj, "ports"))
+
+ # Get VLAN groups for dropdown
+ vlan_groups = self.get_vlan_groups_for_device(obj)
+ lookup_maps = self._build_vlan_lookup_maps(vlan_groups)
+
+ # Load any user VLAN group overrides from cache (set by "apply to all")
+ vlan_group_overrides = cache.get(self.get_vlan_overrides_key(obj)) or {}
if cached_data:
ports_data = cached_data.get("ports", [])
@@ -129,7 +177,7 @@ def get_context_data(self, request, obj, interface_name_field):
for port in ports_data:
port["enabled"] = (
True
- if port["ifAdminStatus"] is None
+ if port.get("ifAdminStatus") is None
else (
port["ifAdminStatus"].lower() == "up"
if isinstance(port["ifAdminStatus"], str)
@@ -138,19 +186,25 @@ def get_context_data(self, request, obj, interface_name_field):
)
if hasattr(obj, "virtual_chassis") and obj.virtual_chassis:
- chassis_member = get_virtual_chassis_member(obj, port[interface_name_field])
+ chassis_member = get_virtual_chassis_member(obj, port.get(interface_name_field))
device_interfaces = interfaces_by_device.get(chassis_member.id, {})
else:
device_interfaces = interfaces_by_device[obj.id]
- netbox_interface = device_interfaces.get(port[interface_name_field])
+ netbox_interface = device_interfaces.get(port.get(interface_name_field))
port["exists_in_netbox"] = bool(netbox_interface)
port["netbox_interface"] = netbox_interface
- if port["ifAlias"] in (port["ifDescr"], port["ifName"]):
+ if port.get("ifAlias") in (port.get("ifDescr"), port.get("ifName")):
port["ifAlias"] = ""
- table = self.get_table(ports_data, obj, interface_name_field)
+ # Add VLAN group auto-selection data to port, applying any user overrides
+ self._add_vlan_group_selection(port, lookup_maps, obj, vlan_group_overrides)
+
+ # Add missing VLANs info for warning display
+ self._add_missing_vlans_info(port, lookup_maps)
+
+ table = self.get_table(ports_data, obj, interface_name_field, vlan_groups=vlan_groups)
table.configure(request)
# Identify NetBox-only interfaces (interfaces in NetBox but not in LibreNMS)
@@ -196,9 +250,118 @@ def get_context_data(self, request, obj, interface_name_field):
return {
"object": obj,
"table": table,
+ "vlan_groups": vlan_groups,
"last_fetched": last_fetched,
"cache_expiry": cache_expiry,
"virtual_chassis_members": virtual_chassis_members,
"interface_name_field": interface_name_field,
"netbox_only_interfaces": netbox_only_interfaces,
}
+
+ def _add_vlan_group_selection(self, port, lookup_maps, device, vlan_group_overrides=None):
+ """
+ Add per-VLAN group auto-selection data to port record.
+
+ Sets:
+ - vlan_group_map: {vid: {"group_id": str, "group_name": str, "is_ambiguous": bool}}
+ Maps each VID to its auto-selected VLAN group based on scope hierarchy.
+ If vlan_group_overrides contains a user selection for a VID, that takes
+ precedence over auto-selection.
+ """
+ vid_to_groups = lookup_maps.get("vid_to_groups", {})
+ untagged_vid = port.get("untagged_vlan")
+ tagged_vids = port.get("tagged_vlans", [])
+
+ all_vids = []
+ if untagged_vid:
+ all_vids.append(untagged_vid)
+ all_vids.extend(tagged_vids)
+
+ vlan_group_map = {}
+ for vid in all_vids:
+ groups = vid_to_groups.get(vid, [])
+ if len(groups) == 1:
+ vlan_group_map[vid] = {
+ "group_id": str(groups[0].pk),
+ "group_name": groups[0].name,
+ "is_ambiguous": False,
+ }
+ elif len(groups) > 1:
+ most_specific = self._select_most_specific_group(groups, device)
+ if most_specific:
+ vlan_group_map[vid] = {
+ "group_id": str(most_specific.pk),
+ "group_name": most_specific.name,
+ "is_ambiguous": False,
+ }
+ else:
+ vlan_group_map[vid] = {
+ "group_id": "",
+ "group_name": "Ambiguous",
+ "is_ambiguous": True,
+ }
+ else:
+ vlan_group_map[vid] = {
+ "group_id": "",
+ "group_name": "Global",
+ "is_ambiguous": False,
+ }
+
+ # Apply user overrides from "apply to all" selections (persisted in cache)
+ if vlan_group_overrides:
+ from ipam.models import VLANGroup
+
+ # Batch-fetch all referenced override group IDs to avoid N+1 queries
+ override_group_ids = {
+ vlan_group_overrides[str(vid)]
+ for vid in all_vids
+ if str(vid) in vlan_group_overrides and vlan_group_overrides[str(vid)]
+ }
+ override_groups_by_id = {}
+ if override_group_ids:
+ override_groups_by_id = VLANGroup.objects.in_bulk(list(override_group_ids))
+
+ for vid in all_vids:
+ vid_str = str(vid)
+ if vid_str in vlan_group_overrides:
+ override_group_id = vlan_group_overrides[vid_str]
+ if override_group_id:
+ group = override_groups_by_id.get(int(override_group_id))
+ if group:
+ vlan_group_map[vid] = {
+ "group_id": str(group.pk),
+ "group_name": group.name,
+ "is_ambiguous": False,
+ }
+ # else: Override references deleted group; keep auto-selection
+ else:
+ # User explicitly chose "No Group (Global)"
+ vlan_group_map[vid] = {
+ "group_id": "",
+ "group_name": "Global",
+ "is_ambiguous": False,
+ }
+
+ port["vlan_group_map"] = vlan_group_map
+
+ def _add_missing_vlans_info(self, port, lookup_maps):
+ """
+ Add missing VLANs info to port record for warning display.
+
+ Sets:
+ - missing_vlans: List of VIDs not found in any NetBox VLAN group
+ """
+ vid_to_vlans = lookup_maps.get("vid_to_vlans", {})
+ missing_vlans = []
+
+ untagged_vid = port.get("untagged_vlan")
+ tagged_vids = port.get("tagged_vlans", [])
+
+ if untagged_vid and untagged_vid not in vid_to_vlans:
+ missing_vlans.append(untagged_vid)
+
+ for vid in tagged_vids:
+ if vid not in vid_to_vlans:
+ missing_vlans.append(vid)
+
+ port["missing_vlans"] = missing_vlans
diff --git a/netbox_librenms_plugin/views/base/ip_addresses_view.py b/netbox_librenms_plugin/views/base/ip_addresses_view.py
index 58b23d8fa1..22f4b49742 100644
--- a/netbox_librenms_plugin/views/base/ip_addresses_view.py
+++ b/netbox_librenms_plugin/views/base/ip_addresses_view.py
@@ -12,10 +12,10 @@
from netbox_librenms_plugin.tables.ipaddresses import IPAddressTable
from netbox_librenms_plugin.utils import get_interface_name_field
-from netbox_librenms_plugin.views.mixins import CacheMixin, LibreNMSAPIMixin
+from netbox_librenms_plugin.views.mixins import CacheMixin, LibreNMSAPIMixin, LibreNMSPermissionMixin
-class BaseIPAddressTableView(LibreNMSAPIMixin, CacheMixin, View):
+class BaseIPAddressTableView(LibreNMSPermissionMixin, LibreNMSAPIMixin, CacheMixin, View):
"""
Base view for synchronizing IP address information from LibreNMS.
"""
@@ -301,7 +301,7 @@ def post(self, request, pk):
)
-class SingleIPAddressVerifyView(CacheMixin, View):
+class SingleIPAddressVerifyView(LibreNMSPermissionMixin, CacheMixin, View):
"""
View for verifying single IP address data with different VRF.
"""
diff --git a/netbox_librenms_plugin/views/base/librenms_sync_view.py b/netbox_librenms_plugin/views/base/librenms_sync_view.py
index 1049d6fc18..1346c3cb13 100644
--- a/netbox_librenms_plugin/views/base/librenms_sync_view.py
+++ b/netbox_librenms_plugin/views/base/librenms_sync_view.py
@@ -3,16 +3,16 @@
from django.shortcuts import get_object_or_404, render
from netbox.views import generic
-from netbox_librenms_plugin.forms import AddToLIbreSNMPV2, AddToLIbreSNMPV3
+from netbox_librenms_plugin.forms import AddToLIbreSNMPV1V2, AddToLIbreSNMPV3
from netbox_librenms_plugin.utils import (
get_interface_name_field,
get_librenms_sync_device,
match_librenms_hardware_to_device_type,
)
-from netbox_librenms_plugin.views.mixins import LibreNMSAPIMixin
+from netbox_librenms_plugin.views.mixins import LibreNMSAPIMixin, LibreNMSPermissionMixin
-class BaseLibreNMSSyncView(LibreNMSAPIMixin, generic.ObjectListView):
+class BaseLibreNMSSyncView(LibreNMSPermissionMixin, LibreNMSAPIMixin, generic.ObjectListView):
"""
Base view for LibreNMS sync information.
"""
@@ -85,6 +85,7 @@ def get_context_data(self, request, obj):
interface_context = self.get_interface_context(request, obj)
cable_context = self.get_cable_context(request, obj)
ip_context = self.get_ip_context(request, obj)
+ vlan_context = self.get_vlan_context(request, obj)
interface_name_field = get_interface_name_field(request)
@@ -101,7 +102,8 @@ def get_context_data(self, request, obj):
"interface_sync": interface_context,
"cable_sync": cable_context,
"ip_sync": ip_context,
- "v2form": AddToLIbreSNMPV2(prefix="v2"),
+ "vlan_sync": vlan_context,
+ "v1v2form": AddToLIbreSNMPV1V2(prefix="v1v2"),
"v3form": AddToLIbreSNMPV3(prefix="v3"),
"librenms_device_id": self.librenms_id,
"found_in_librenms": librenms_info.get("found_in_librenms"),
@@ -137,11 +139,11 @@ def get_librenms_device_info(self, obj):
success, device_info = self.librenms_api.get_device_info(self.librenms_id)
if success and device_info:
# Get NetBox device details
- netbox_ip = str(obj.primary_ip.address.ip) if obj.primary_ip else None
- netbox_hostname = obj.name
+ netbox_ip = str(obj.primary_ip.address.ip).lower() if obj.primary_ip else None
+ netbox_name = obj.name
# Get LibreNMS device details
- librenms_hostname = device_info.get("sysName")
+ librenms_sysname = device_info.get("sysName")
librenms_ip = device_info.get("ip")
# Extract new fields
@@ -165,7 +167,8 @@ def get_librenms_device_info(self, obj):
"librenms_device_features": features,
"librenms_device_location": device_info.get("location", "-"),
"librenms_device_ip": librenms_ip,
- "sysName": librenms_hostname,
+ "sysName": librenms_sysname,
+ "librenms_device_hostname": device_info.get("hostname", "-"),
"librenms_device_hardware_match": hardware_match,
}
)
@@ -175,40 +178,62 @@ def get_librenms_device_info(self, obj):
vc_serials = self._get_vc_inventory_serials(obj)
librenms_device_details["vc_inventory_serials"] = vc_serials
- # Get just the hostname part from LibreNMS FQDN if present
- librenms_host = librenms_hostname.split(".")[0].lower() if librenms_hostname else None
- netbox_host = netbox_hostname.split(".")[0].lower() if netbox_hostname else None
-
- # Check for matching IP or hostname
- # If IP matches, we have a match
- if netbox_ip == librenms_ip:
- found_in_librenms = True
- # Check hostname match with normalization for VC suffixes
- elif netbox_host and librenms_host:
- # Normalize NetBox hostname by removing VC member suffixes like ' (1)', ' (2)', etc.
- netbox_host_normalized = re.sub(r"\s*\(\d+\)$", "", netbox_host)
-
- if netbox_host_normalized == librenms_host:
- found_in_librenms = True
- # For VC members with explicit librenms_id, validate hostname similarity
- elif hasattr(obj, "virtual_chassis") and obj.virtual_chassis and obj.cf.get("librenms_id"):
- # Extract base hostname (before any VC numbering like -1, -2, etc.)
- # This handles cases where VC members in NetBox (e.g., "switch-1 (1)")
- # point to the primary device in LibreNMS (e.g., "switch-1")
- netbox_base = re.sub(r"[-_]?\d+$", "", netbox_host_normalized)
- librenms_base = re.sub(r"[-_]?\d+$", "", librenms_host)
-
- if netbox_base and librenms_base and netbox_base == librenms_base:
- # Base hostnames match (e.g., "switch" matches "switch")
- found_in_librenms = True
- else:
- # Hostnames don't match even after normalization
- mismatched_device = True
- else:
- mismatched_device = True
+ # Device was retrieved successfully via librenms_id β trust the ID
+ found_in_librenms = True
+
+ # Normalise the NetBox name once for comparisons
+ netbox_name_norm = netbox_name.lower() if netbox_name else None
+ if netbox_name_norm:
+ # Strip VC member suffix like " (1)" before comparing
+ netbox_name_norm = re.sub(r"\s*\(\d+\)$", "", netbox_name_norm)
+
+ # Also strip the VC member naming pattern from settings
+ # (e.g. "-M2", " (2)", "-SW3") to recover the base device name
+ netbox_name_vc_stripped = None
+ if netbox_name_norm:
+ netbox_name_vc_stripped = self._strip_vc_pattern(netbox_name_norm)
+
+ # Collect all NetBox identity values to compare against
+ netbox_dns_name = (
+ obj.primary_ip.dns_name.lower() if obj.primary_ip and obj.primary_ip.dns_name else None
+ )
+ netbox_identities = {
+ v
+ for v in [
+ netbox_name_norm,
+ netbox_ip,
+ netbox_dns_name,
+ netbox_name_vc_stripped,
+ ]
+ if v
+ }
+
+ # Collect all LibreNMS identity values, including
+ # domain-stripped short names (e.g. "sw01.example.net" β "sw01")
+ librenms_hostname = device_info.get("hostname")
+ librenms_values = []
+ for val in [librenms_sysname, librenms_hostname, librenms_ip]:
+ if val:
+ lower_val = val.lower()
+ librenms_values.append(lower_val)
+ # Add short name (strip domain) if it looks like an FQDN
+ short = lower_val.split(".")[0]
+ if short != lower_val:
+ librenms_values.append(short)
+ librenms_identities = set(librenms_values)
+
+ # A device is considered matched when ANY NetBox identity
+ # appears in the LibreNMS identities. This covers:
+ # - NetBox name == sysName or hostname
+ # - NetBox primary IP == LibreNMS hostname (added by IP)
+ # - NetBox DNS name == sysName or hostname (FQDN match)
+ if netbox_identities & librenms_identities:
+ mismatched_device = False
else:
mismatched_device = True
+ librenms_device_details["netbox_dns_name"] = netbox_dns_name or "-"
+
return {
"found_in_librenms": found_in_librenms,
"librenms_device_details": librenms_device_details,
@@ -236,6 +261,48 @@ def get_ip_context(self, request, obj):
"""
return None
+ def get_vlan_context(self, request, obj):
+ """
+ Get the context data for VLAN sync.
+ Subclasses should override this method.
+ """
+ return None
+
+ @staticmethod
+ def _strip_vc_pattern(name):
+ """Strip the VC member naming suffix from a device name.
+
+ Uses the vc_member_name_pattern from LibreNMSSettings to build a
+ regex that removes the suffix. For example, with the default
+ pattern ``-M{position}`` and name ``switch01-m2``, this returns
+ ``switch01``.
+
+ Returns the stripped name, or None if it equals the original
+ (i.e. no suffix was found).
+ """
+ try:
+ from netbox_librenms_plugin.models import LibreNMSSettings
+
+ settings = LibreNMSSettings.objects.first()
+ pattern = (
+ settings.vc_member_name_pattern
+ if settings and isinstance(settings.vc_member_name_pattern, str)
+ else "-M{position}"
+ )
+ if not isinstance(pattern, str):
+ pattern = "-M{position}"
+
+ # Turn the pattern into a regex by replacing placeholders
+ # {position} β \d+ {serial} β .+
+ regex_suffix = re.escape(pattern)
+ regex_suffix = regex_suffix.replace(re.escape("{position}"), r"\d+")
+ regex_suffix = regex_suffix.replace(re.escape("{serial}"), r".+")
+
+ stripped = re.sub(regex_suffix + "$", "", name, flags=re.IGNORECASE)
+ return stripped if stripped != name else None
+ except Exception:
+ return None
+
def _get_vc_inventory_serials(self, obj):
"""
Fetch inventory serials for Virtual Chassis members.
diff --git a/netbox_librenms_plugin/views/base/vlan_table_view.py b/netbox_librenms_plugin/views/base/vlan_table_view.py
new file mode 100644
index 0000000000..78bbfb0fca
--- /dev/null
+++ b/netbox_librenms_plugin/views/base/vlan_table_view.py
@@ -0,0 +1,212 @@
+from django.contrib import messages
+from django.core.cache import cache
+from django.shortcuts import get_object_or_404, render
+from django.utils import timezone
+from django.views import View
+
+from netbox_librenms_plugin.constants import LIBRENMS_VLAN_STATE_ACTIVE
+from netbox_librenms_plugin.tables.vlans import LibreNMSVLANTable
+from netbox_librenms_plugin.views.mixins import (
+ CacheMixin,
+ LibreNMSAPIMixin,
+ LibreNMSPermissionMixin,
+ VlanAssignmentMixin,
+)
+
+
+class BaseVLANTableView(VlanAssignmentMixin, LibreNMSAPIMixin, LibreNMSPermissionMixin, CacheMixin, View):
+ """
+ Base view for VLAN synchronization table.
+ Fetches LibreNMS VLAN data and compares with NetBox.
+ """
+
+ model = None # To be defined in subclasses
+ partial_template_name = "netbox_librenms_plugin/_vlan_sync_content.html"
+
+ def get_object(self, pk):
+ """Retrieve the object (Device or VirtualMachine)."""
+ return get_object_or_404(self.model, pk=pk)
+
+ def post(self, request, pk):
+ """Handle POST request to fetch and cache LibreNMS VLAN data."""
+ obj = self.get_object(pk)
+
+ # Get librenms_id
+ self.librenms_id = self.librenms_api.get_librenms_id(obj)
+
+ if not self.librenms_id:
+ messages.error(request, "Device not found in LibreNMS.")
+ context = {"vlan_sync": self._get_error_context(obj, "Device not found in LibreNMS")}
+ return render(request, self.partial_template_name, context)
+
+ # Fetch VLAN data from LibreNMS
+ success, error_msg = self._fetch_and_cache_vlan_data(obj)
+ if not success:
+ messages.error(request, error_msg)
+ context = {"vlan_sync": self._get_error_context(obj, error_msg)}
+ return render(request, self.partial_template_name, context)
+
+ messages.success(request, "VLAN data refreshed successfully.")
+
+ context = {"vlan_sync": self.get_vlan_context(request, obj)}
+ return render(request, self.partial_template_name, context)
+
+ def _fetch_and_cache_vlan_data(self, obj):
+ """
+ Fetch VLAN data from LibreNMS and cache it.
+
+ Returns:
+ tuple: (success: bool, error_message: str or None)
+ """
+ # Fetch device VLANs
+ success, vlans_data = self.librenms_api.get_device_vlans(self.librenms_id)
+ if not success:
+ return False, f"Failed to fetch VLANs: {vlans_data}"
+
+ # Cache VLANs
+ cache.set(
+ self.get_cache_key(obj, "vlans"),
+ vlans_data,
+ timeout=self.librenms_api.cache_timeout,
+ )
+ cache.set(
+ self.get_last_fetched_key(obj, "vlans"),
+ timezone.now(),
+ timeout=self.librenms_api.cache_timeout,
+ )
+
+ return True, None
+
+ def get_vlan_context(self, request, obj):
+ """
+ Build context for VLAN sync table.
+
+ Returns context with:
+ - vlan_table: LibreNMSVLANTable instance
+ - vlan_groups: QuerySet of available VLAN groups
+ """
+ vlan_table = None
+
+ # Get cached data
+ cached_vlans = cache.get(self.get_cache_key(obj, "vlans"))
+ last_fetched = cache.get(self.get_last_fetched_key(obj, "vlans"))
+
+ # Get available VLAN groups for this device
+ vlan_groups = self.get_vlan_groups_for_device(obj)
+
+ # Build lookup maps for VLAN matching
+ lookup_maps = self._build_vlan_lookup_maps(vlan_groups)
+
+ if cached_vlans:
+ # Compare VLANs with NetBox (against all device-available VLANs)
+ compared_vlans = self.compare_vlans(cached_vlans, lookup_maps, device=obj)
+
+ vlan_table = LibreNMSVLANTable(compared_vlans, vlan_groups=vlan_groups)
+ vlan_table.configure(request)
+
+ # Calculate cache TTL
+ cache_ttl = cache.ttl(self.get_cache_key(obj, "vlans"))
+ cache_expiry = timezone.now() + timezone.timedelta(seconds=cache_ttl) if cache_ttl else None
+
+ return {
+ "object": obj,
+ "vlan_table": vlan_table,
+ "vlan_groups": vlan_groups,
+ "last_fetched": last_fetched,
+ "cache_expiry": cache_expiry,
+ }
+
+ def _get_error_context(self, obj, error_message):
+ """Build context for error state."""
+ return {
+ "object": obj,
+ "error_message": error_message,
+ "vlan_table": None,
+ "vlan_groups": self.get_vlan_groups_for_device(obj),
+ }
+
+ def compare_vlans(self, librenms_vlans, lookup_maps=None, device=None):
+ """
+ Compare LibreNMS VLANs against NetBox VLANs available to the device.
+
+ Args:
+ librenms_vlans: List of VLAN dicts from LibreNMS
+ lookup_maps: Dict with vid_to_groups, vid_group_to_vlan, vid_to_vlans
+ device: NetBox Device object for scope-based prioritization
+
+ Adds comparison flags:
+ - exists_in_netbox: bool
+ - netbox_vlan: VLAN object or None
+ - netbox_vlan_group: VLANGroup name or None
+ - name_matches: bool
+ - auto_selected_group_id: ID of auto-selected group or None
+ - auto_selected_group_name: Name of auto-selected group or None
+ - is_ambiguous: bool - True if VID exists in multiple groups with no clear priority
+ """
+ lookup_maps = lookup_maps or {}
+ vid_to_groups = lookup_maps.get("vid_to_groups", {})
+ vid_to_vlans = lookup_maps.get("vid_to_vlans", {})
+
+ compared = []
+ for vlan in librenms_vlans:
+ vid = vlan.get("vlan_vlan")
+ name = vlan.get("vlan_name", "")
+
+ # Auto-selection logic for VLAN group dropdown
+ auto_selected_group_id = None
+ auto_selected_group_name = None
+ is_ambiguous = False
+ netbox_vlan = None
+
+ # Check if VID exists in groups for auto-selection
+ if vid in vid_to_groups:
+ groups = vid_to_groups[vid]
+ if len(groups) == 1:
+ auto_selected_group_id = groups[0].pk
+ auto_selected_group_name = groups[0].name
+ # Get the VLAN from this single group
+ vlans_for_vid = vid_to_vlans.get(vid, [])
+ if vlans_for_vid:
+ netbox_vlan = vlans_for_vid[0]
+ elif len(groups) > 1:
+ # Try to select the most specific group based on device context
+ most_specific = self._select_most_specific_group(groups, device)
+ if most_specific:
+ auto_selected_group_id = most_specific.pk
+ auto_selected_group_name = most_specific.name
+ # Get the VLAN from the most specific group
+ vlans_for_vid = vid_to_vlans.get(vid, [])
+ for v in vlans_for_vid:
+ if v.group and v.group.pk == most_specific.pk:
+ netbox_vlan = v
+ break
+ else:
+ is_ambiguous = True
+ else:
+ # Check if it exists as a global VLAN (no group)
+ vlans_for_vid = vid_to_vlans.get(vid, [])
+ for v in vlans_for_vid:
+ if v.group is None:
+ netbox_vlan = v
+ break
+
+ compared.append(
+ {
+ "vlan_id": vid,
+ "name": name,
+ "type": vlan.get("vlan_type", "ethernet"),
+ "state": vlan.get("vlan_state", LIBRENMS_VLAN_STATE_ACTIVE),
+ "exists_in_netbox": bool(netbox_vlan),
+ "netbox_vlan_id": netbox_vlan.pk if netbox_vlan else None,
+ "netbox_vlan_name": netbox_vlan.name if netbox_vlan else None,
+ "netbox_vlan_group": netbox_vlan.group.name if netbox_vlan and netbox_vlan.group else None,
+ "netbox_vlan_group_id": netbox_vlan.group.pk if netbox_vlan and netbox_vlan.group else None,
+ "name_matches": netbox_vlan.name == name if netbox_vlan else False,
+ # Fields for per-row VLAN group selection
+ "auto_selected_group_id": auto_selected_group_id,
+ "auto_selected_group_name": auto_selected_group_name,
+ "is_ambiguous": is_ambiguous,
+ }
+ )
+
+ return compared
diff --git a/netbox_librenms_plugin/views/imports/__init__.py b/netbox_librenms_plugin/views/imports/__init__.py
index 8b1f4501ae..b6d22d0e1d 100644
--- a/netbox_librenms_plugin/views/imports/__init__.py
+++ b/netbox_librenms_plugin/views/imports/__init__.py
@@ -8,6 +8,7 @@
DeviceRoleUpdateView,
DeviceValidationDetailsView,
DeviceVCDetailsView,
+ SaveUserPrefView,
)
from .list import LibreNMSImportView # noqa: F401
@@ -20,4 +21,5 @@
"DeviceValidationDetailsView",
"DeviceVCDetailsView",
"LibreNMSImportView",
+ "SaveUserPrefView",
]
diff --git a/netbox_librenms_plugin/views/imports/actions.py b/netbox_librenms_plugin/views/imports/actions.py
index 82e4c6c34e..46b10b117a 100644
--- a/netbox_librenms_plugin/views/imports/actions.py
+++ b/netbox_librenms_plugin/views/imports/actions.py
@@ -1,10 +1,12 @@
"""HTMX endpoints and POST handlers for importing LibreNMS devices."""
+import json
import logging
from django.contrib import messages
from django.core.cache import cache
-from django.http import HttpResponse
+from django.core.exceptions import PermissionDenied
+from django.http import HttpResponse, JsonResponse
from django.shortcuts import redirect, render
from django.views import View
@@ -27,7 +29,8 @@
fetch_model_by_id,
)
from netbox_librenms_plugin.tables.device_status import DeviceImportTable
-from netbox_librenms_plugin.views.mixins import LibreNMSAPIMixin
+from netbox_librenms_plugin.utils import save_user_pref
+from netbox_librenms_plugin.views.mixins import LibreNMSAPIMixin, LibreNMSPermissionMixin
logger = logging.getLogger(__name__)
@@ -204,10 +207,15 @@ def _apply_user_selections_to_validation(
apply_rack_to_validation(validation, rack)
-class BulkImportConfirmView(LibreNMSAPIMixin, View):
+class BulkImportConfirmView(LibreNMSPermissionMixin, LibreNMSAPIMixin, View):
"""HTMX view to confirm bulk imports before execution."""
def post(self, request):
+ """Render a confirmation modal for selected devices before bulk import."""
+ # Check write permission before showing import confirmation
+ if error := self.require_write_permission():
+ return error
+
device_ids = request.POST.getlist("select")
if not device_ids:
return HttpResponse(
@@ -352,7 +360,7 @@ def post(self, request):
)
-class BulkImportDevicesView(LibreNMSAPIMixin, View):
+class BulkImportDevicesView(LibreNMSPermissionMixin, LibreNMSAPIMixin, View):
"""Handle bulk import requests coming from the LibreNMS import table."""
def should_use_background_job_for_import(self, request):
@@ -362,15 +370,26 @@ def should_use_background_job_for_import(self, request):
Import jobs provide active cancellation and keep the browser responsive
during bulk imports.
+ Note: Non-superusers automatically fall back to synchronous mode because
+ the /api/core/background-tasks/ endpoint requires superuser access.
+
Args:
request: Django request object containing POST data
Returns:
bool: True if background job should be used, False for synchronous
"""
+ # Non-superusers cannot poll background-tasks API (requires IsSuperuser)
+ if not request.user.is_superuser:
+ return False
return request.POST.get("use_background_job") == "on"
def post(self, request): # noqa: PLR0912 - branching keeps responses explicit
+ """Import selected devices from LibreNMS into NetBox."""
+ # Check write permission before any import operation
+ if error := self.require_write_permission():
+ return error
+
device_ids = request.POST.getlist("select")
if not device_ids:
messages.error(request, "No devices selected for import")
@@ -529,6 +548,7 @@ def post(self, request): # noqa: PLR0912 - branching keeps responses explicit
sync_options=sync_options,
manual_mappings_per_device=manual_mappings_per_device, # type: ignore
libre_devices_cache=libre_devices_cache_sync,
+ user=request.user, # Pass user for permission checks
)
# Import VMs if any
@@ -538,8 +558,20 @@ def post(self, request): # noqa: PLR0912 - branching keeps responses explicit
self.librenms_api,
sync_options,
libre_devices_cache_sync,
+ user=request.user, # Pass user for permission checks
)
+ except PermissionDenied as exc:
+ # Handle permission errors with a user-friendly message
+ logger.warning(f"Permission denied during import: {exc}")
+ messages.error(request, str(exc))
+ if request.headers.get("HX-Request"):
+ return HttpResponse(
+ "",
+ headers={"HX-Redirect": "/plugins/librenms_plugin/librenms-import/"},
+ )
+ return redirect("plugins:netbox_librenms_plugin:librenms_import")
+
except Exception as exc: # pragma: no cover - defensive guard
logger.exception("Error during bulk import")
if request.headers.get("HX-Request"):
@@ -631,10 +663,11 @@ def post(self, request): # noqa: PLR0912 - branching keeps responses explicit
return redirect("plugins:netbox_librenms_plugin:librenms_import")
-class DeviceVCDetailsView(LibreNMSAPIMixin, View):
+class DeviceVCDetailsView(LibreNMSPermissionMixin, LibreNMSAPIMixin, View):
"""HTMX view to show virtual chassis details."""
def get(self, request, device_id):
+ """Render virtual chassis details for a LibreNMS device."""
libre_device = get_librenms_device_by_id(self.librenms_api, device_id)
if not libre_device:
return HttpResponse(
@@ -656,10 +689,11 @@ def get(self, request, device_id):
)
-class DeviceValidationDetailsView(LibreNMSAPIMixin, DeviceImportHelperMixin, View):
+class DeviceValidationDetailsView(LibreNMSPermissionMixin, LibreNMSAPIMixin, DeviceImportHelperMixin, View):
"""HTMX view to show detailed validation information."""
def get(self, request, device_id):
+ """Render detailed validation information for a LibreNMS device."""
libre_device, validation, selections = self.get_validated_device_with_selections(device_id, request)
if not libre_device:
@@ -680,10 +714,11 @@ def get(self, request, device_id):
)
-class DeviceRoleUpdateView(LibreNMSAPIMixin, DeviceImportHelperMixin, View):
+class DeviceRoleUpdateView(LibreNMSPermissionMixin, LibreNMSAPIMixin, DeviceImportHelperMixin, View):
"""HTMX view to update a table row when a role is selected."""
def post(self, request, device_id):
+ """Update the table row after a device role selection change."""
libre_device, validation, selections = self.get_validated_device_with_selections(device_id, request)
if not libre_device:
@@ -692,10 +727,11 @@ def post(self, request, device_id):
return self.render_device_row(request, libre_device, validation, selections)
-class DeviceClusterUpdateView(LibreNMSAPIMixin, DeviceImportHelperMixin, View):
+class DeviceClusterUpdateView(LibreNMSPermissionMixin, LibreNMSAPIMixin, DeviceImportHelperMixin, View):
"""HTMX view to update a table row when a cluster is selected/deselected."""
def post(self, request, device_id):
+ """Update the table row after a cluster selection change."""
libre_device, validation, selections = self.get_validated_device_with_selections(device_id, request)
if not libre_device:
@@ -704,13 +740,40 @@ def post(self, request, device_id):
return self.render_device_row(request, libre_device, validation, selections)
-class DeviceRackUpdateView(LibreNMSAPIMixin, DeviceImportHelperMixin, View):
+class DeviceRackUpdateView(LibreNMSPermissionMixin, LibreNMSAPIMixin, DeviceImportHelperMixin, View):
"""HTMX view to update a table row when a rack is selected."""
def post(self, request, device_id):
+ """Update the table row after a rack selection change."""
libre_device, validation, selections = self.get_validated_device_with_selections(device_id, request)
if not libre_device:
return HttpResponse("Device not found", status=404)
return self.render_device_row(request, libre_device, validation, selections)
+
+
+class SaveUserPrefView(LibreNMSPermissionMixin, View):
+ """Save a user preference via POST. Used by JS toggle handlers."""
+
+ ALLOWED_PREFS = {
+ "use_sysname": "plugins.netbox_librenms_plugin.use_sysname",
+ "strip_domain": "plugins.netbox_librenms_plugin.strip_domain",
+ "interface_name_field": "plugins.netbox_librenms_plugin.interface_name_field",
+ }
+
+ def post(self, request):
+ """Persist a user preference toggle value."""
+ try:
+ data = json.loads(request.body)
+ except (json.JSONDecodeError, ValueError):
+ return JsonResponse({"error": "Invalid JSON"}, status=400)
+
+ key = data.get("key")
+ value = data.get("value")
+
+ if key not in self.ALLOWED_PREFS:
+ return JsonResponse({"error": "Invalid preference key"}, status=400)
+
+ save_user_pref(request, self.ALLOWED_PREFS[key], value)
+ return JsonResponse({"status": "ok"})
diff --git a/netbox_librenms_plugin/views/imports/list.py b/netbox_librenms_plugin/views/imports/list.py
index 4b1a226513..b6222a5905 100644
--- a/netbox_librenms_plugin/views/imports/list.py
+++ b/netbox_librenms_plugin/views/imports/list.py
@@ -15,12 +15,13 @@
)
from netbox_librenms_plugin.models import LibreNMSSettings
from netbox_librenms_plugin.tables.device_status import DeviceImportTable
-from netbox_librenms_plugin.views.mixins import LibreNMSAPIMixin
+from netbox_librenms_plugin.utils import get_user_pref
+from netbox_librenms_plugin.views.mixins import LibreNMSAPIMixin, LibreNMSPermissionMixin
logger = logging.getLogger(__name__)
-class LibreNMSImportView(LibreNMSAPIMixin, generic.ObjectListView):
+class LibreNMSImportView(LibreNMSPermissionMixin, LibreNMSAPIMixin, generic.ObjectListView):
"""Import devices from LibreNMS into NetBox with validation metadata."""
queryset = Device.objects.none()
@@ -32,6 +33,7 @@ class LibreNMSImportView(LibreNMSAPIMixin, generic.ObjectListView):
title = "Import Devices from LibreNMS"
def get_required_permission(self):
+ """Return the permission required to view the import list."""
from utilities.permissions import get_permission_for_model
return get_permission_for_model(Device, "view")
@@ -49,9 +51,15 @@ def should_use_background_job(self):
- Job tracking in NetBox Jobs interface
- Results cached for later retrieval
+ Note: Non-superusers automatically fall back to synchronous mode because
+ the /api/core/background-tasks/ endpoint requires superuser access.
+
Returns:
bool: True if background job should be used, False for synchronous
"""
+ # Non-superusers cannot poll background-tasks API (requires IsSuperuser)
+ if not self.request.user.is_superuser:
+ return False
return self._filter_form_data.get("use_background_job", True)
def _load_job_results(self, job_id):
@@ -299,6 +307,15 @@ def get(self, request, *args, **kwargs): # noqa: D401 - inherited doc
except Exception:
settings = None
+ # User preference overrides for toggles (persisted per-user)
+ use_sysname = get_user_pref(request, "plugins.netbox_librenms_plugin.use_sysname")
+ strip_domain = get_user_pref(request, "plugins.netbox_librenms_plugin.strip_domain")
+ # Fall back to server-level settings
+ if use_sysname is None:
+ use_sysname = getattr(settings, "use_sysname_default", True) if settings else True
+ if strip_domain is None:
+ strip_domain = getattr(settings, "strip_domain_default", False) if settings else False
+
# Get active cached searches for this server
cached_searches = get_active_cached_searches(self.librenms_api.server_key)
@@ -311,6 +328,8 @@ def get(self, request, *args, **kwargs): # noqa: D401 - inherited doc
"filters_submitted": filters_submitted,
"show_filter_warning": bool(filter_warning),
"settings": settings,
+ "use_sysname": use_sysname,
+ "strip_domain": strip_domain,
"vc_detection_enabled": getattr(self, "_vc_detection_enabled", False),
"cache_cleared": getattr(self, "_cache_cleared", False),
"from_cache": getattr(self, "_from_cache", False),
@@ -319,20 +338,26 @@ def get(self, request, *args, **kwargs): # noqa: D401 - inherited doc
"cache_metadata_missing": getattr(self, "_cache_metadata_missing", False),
"cached_searches": cached_searches,
"librenms_server_info": self.get_server_info(),
+ "can_use_background_jobs": request.user.is_superuser,
}
return render(request, self.template_name, context)
def get_queryset(self, request): # noqa: D401 - inherited doc
+ """Load import data into _import_data and return an empty Device queryset."""
import_data = self._get_import_queryset()
self._import_data = import_data
return Device.objects.none()
def get_table(self, data, request, bulk_actions=True):
+ """Return a DeviceImportTable populated with validated import data."""
if not hasattr(self, "_import_data"):
self._import_data = self._get_import_queryset()
data = self._import_data
- table = DeviceImportTable(data, order_by=request.GET.get("sort"))
+ table = DeviceImportTable(
+ data,
+ order_by=request.GET.get("sort"),
+ )
return table
def _get_import_queryset(self):
diff --git a/netbox_librenms_plugin/views/mapping_views.py b/netbox_librenms_plugin/views/mapping_views.py
index 2252efd835..b1fcec9c77 100644
--- a/netbox_librenms_plugin/views/mapping_views.py
+++ b/netbox_librenms_plugin/views/mapping_views.py
@@ -9,9 +9,10 @@
)
from netbox_librenms_plugin.models import InterfaceTypeMapping
from netbox_librenms_plugin.tables.mappings import InterfaceTypeMappingTable
+from netbox_librenms_plugin.views.mixins import LibreNMSPermissionMixin
-class InterfaceTypeMappingListView(generic.ObjectListView):
+class InterfaceTypeMappingListView(LibreNMSPermissionMixin, generic.ObjectListView):
"""
Provides a view for listing all `InterfaceTypeMapping` objects.
"""
@@ -23,7 +24,7 @@ class InterfaceTypeMappingListView(generic.ObjectListView):
template_name = "netbox_librenms_plugin/interfacetypemapping_list.html"
-class InterfaceTypeMappingCreateView(generic.ObjectEditView):
+class InterfaceTypeMappingCreateView(LibreNMSPermissionMixin, generic.ObjectEditView):
"""
Provides a view for creating a new `InterfaceTypeMapping` object.
"""
@@ -33,7 +34,7 @@ class InterfaceTypeMappingCreateView(generic.ObjectEditView):
@register_model_view(InterfaceTypeMapping, "bulk_import", path="import", detail=False)
-class InterfaceTypeMappingBulkImportView(generic.BulkImportView):
+class InterfaceTypeMappingBulkImportView(LibreNMSPermissionMixin, generic.BulkImportView):
"""
Provides a view for bulk importing `InterfaceTypeMapping` objects from CSV, JSON, or YAML.
Supports three import methods: direct import, file upload, and data file.
@@ -43,7 +44,7 @@ class InterfaceTypeMappingBulkImportView(generic.BulkImportView):
model_form = InterfaceTypeMappingImportForm
-class InterfaceTypeMappingView(generic.ObjectView):
+class InterfaceTypeMappingView(LibreNMSPermissionMixin, generic.ObjectView):
"""
Provides a view for displaying details of a specific `InterfaceTypeMapping` object.
"""
@@ -51,7 +52,7 @@ class InterfaceTypeMappingView(generic.ObjectView):
queryset = InterfaceTypeMapping.objects.all()
-class InterfaceTypeMappingEditView(generic.ObjectEditView):
+class InterfaceTypeMappingEditView(LibreNMSPermissionMixin, generic.ObjectEditView):
"""
Provides a view for editing a specific `InterfaceTypeMapping` object.
"""
@@ -60,7 +61,7 @@ class InterfaceTypeMappingEditView(generic.ObjectEditView):
form = InterfaceTypeMappingForm
-class InterfaceTypeMappingDeleteView(generic.ObjectDeleteView):
+class InterfaceTypeMappingDeleteView(LibreNMSPermissionMixin, generic.ObjectDeleteView):
"""
Provides a view for deleting a specific `InterfaceTypeMapping` object.
"""
@@ -68,7 +69,7 @@ class InterfaceTypeMappingDeleteView(generic.ObjectDeleteView):
queryset = InterfaceTypeMapping.objects.all()
-class InterfaceTypeMappingBulkDeleteView(generic.BulkDeleteView):
+class InterfaceTypeMappingBulkDeleteView(LibreNMSPermissionMixin, generic.BulkDeleteView):
"""
Provides a view for deleting multiple `InterfaceTypeMapping` objects.
"""
@@ -77,7 +78,7 @@ class InterfaceTypeMappingBulkDeleteView(generic.BulkDeleteView):
table = InterfaceTypeMappingTable
-class InterfaceTypeMappingChangeLogView(generic.ObjectChangeLogView):
+class InterfaceTypeMappingChangeLogView(LibreNMSPermissionMixin, generic.ObjectChangeLogView):
"""
Provides a view for displaying the change log of a specific `InterfaceTypeMapping` object.
"""
diff --git a/netbox_librenms_plugin/views/mixins.py b/netbox_librenms_plugin/views/mixins.py
index 49795fe153..73513f88df 100644
--- a/netbox_librenms_plugin/views/mixins.py
+++ b/netbox_librenms_plugin/views/mixins.py
@@ -1,6 +1,198 @@
+from django.contrib import messages
+from django.contrib.auth.mixins import PermissionRequiredMixin
+from django.http import HttpResponse
+from django.shortcuts import redirect
+from django.utils.http import url_has_allowed_host_and_scheme
+from utilities.permissions import get_permission_for_model
+
+from netbox_librenms_plugin.constants import PERM_CHANGE_PLUGIN, PERM_VIEW_PLUGIN
from netbox_librenms_plugin.librenms_api import LibreNMSAPI
+def _get_safe_redirect_url(request):
+ """Return a validated redirect URL from the HTTP Referer header.
+
+ Validates the Referer against allowed hosts and schemes to prevent
+ open-redirect attacks. Falls back to the current request path or "/".
+ """
+ referrer = request.META.get("HTTP_REFERER")
+ if referrer and url_has_allowed_host_and_scheme(
+ referrer,
+ allowed_hosts={request.get_host()},
+ require_https=request.is_secure(),
+ ):
+ return referrer
+ return getattr(request, "path", "/")
+
+
+class LibreNMSPermissionMixin(PermissionRequiredMixin):
+ """
+ Mixin for views requiring LibreNMS plugin permissions.
+
+ All plugin views require 'view_librenmssettings' to access the page.
+ Write actions require 'change_librenmssettings' plus any relevant
+ NetBox object permissions.
+ """
+
+ permission_required = PERM_VIEW_PLUGIN
+
+ def has_write_permission(self):
+ """Check if user can perform write actions."""
+ return self.request.user.has_perm(PERM_CHANGE_PLUGIN)
+
+ def require_write_permission(self, error_message=None):
+ """
+ Check write permission and return error response if denied.
+
+ Handles both HTMX and regular requests appropriately:
+ - HTMX: Returns HX-Redirect to referrer with toast message
+ - Regular: Returns redirect to referrer with flash message
+
+ Returns:
+ None if permitted, or appropriate response if denied
+ """
+ if not self.has_write_permission():
+ msg = error_message or "You do not have permission to perform this action."
+ messages.error(self.request, msg)
+
+ referrer = _get_safe_redirect_url(self.request)
+
+ # Check if this is an HTMX request
+ if self.request.headers.get("HX-Request"):
+ return HttpResponse("", headers={"HX-Redirect": referrer})
+
+ return redirect(referrer)
+ return None
+
+ def require_write_permission_json(self, error_message=None):
+ """
+ Check write permission and return JSON error response if denied.
+
+ Use this method for AJAX/HTMX endpoints that return JsonResponse.
+ Does not set flash messages since JSON clients handle errors differently.
+
+ Returns:
+ None if permitted, or JsonResponse with 403 status if denied
+ """
+ from django.http import JsonResponse
+
+ if not self.has_write_permission():
+ msg = error_message or "You do not have permission to perform this action."
+ return JsonResponse({"error": msg}, status=403)
+ return None
+
+
+class NetBoxObjectPermissionMixin:
+ """
+ Mixin for views requiring specific NetBox object permissions.
+
+ Define required_object_permissions as a dict mapping HTTP methods
+ to lists of (action, model) tuples.
+
+ Example:
+ required_object_permissions = {
+ 'POST': [
+ ('add', Interface),
+ ('change', Interface),
+ ],
+ }
+ """
+
+ required_object_permissions = {}
+
+ def check_object_permissions(self, method):
+ """
+ Check all required object permissions for the given HTTP method.
+
+ Args:
+ method: HTTP method (GET, POST, etc.)
+
+ Returns:
+ tuple: (has_all: bool, missing: list[str])
+ """
+ requirements = self.required_object_permissions.get(method, [])
+ missing = []
+
+ for action, model in requirements:
+ perm = get_permission_for_model(model, action)
+ if not self.request.user.has_perm(perm):
+ missing.append(perm)
+
+ return (len(missing) == 0, missing)
+
+ def require_object_permissions(self, method):
+ """
+ Require all object permissions for the method, returning error response if denied.
+
+ Handles both HTMX and regular requests appropriately:
+ - HTMX: Returns HX-Redirect to referrer with flash message
+ - Regular: Returns redirect to referrer with flash message
+
+ Returns:
+ None if permitted, or appropriate response if denied
+ """
+ has_perms, missing = self.check_object_permissions(method)
+ if not has_perms:
+ missing_str = ", ".join(missing)
+ msg = f"Missing permissions: {missing_str}"
+ messages.error(self.request, msg)
+
+ referrer = _get_safe_redirect_url(self.request)
+
+ # Check if this is an HTMX request
+ if self.request.headers.get("HX-Request"):
+ return HttpResponse("", headers={"HX-Redirect": referrer})
+
+ return redirect(referrer)
+ return None
+
+ def require_object_permissions_json(self, method):
+ """
+ Require all object permissions for the method, returning JSON error if denied.
+
+ Use this method for AJAX/HTMX endpoints that return JsonResponse.
+ Does not set flash messages since JSON clients handle errors differently.
+
+ Returns:
+ None if permitted, or JsonResponse with 403 status if denied
+ """
+ from django.http import JsonResponse
+
+ has_perms, missing = self.check_object_permissions(method)
+ if not has_perms:
+ missing_str = ", ".join(missing)
+ return JsonResponse({"error": f"Missing permissions: {missing_str}"}, status=403)
+ return None
+
+ def require_all_permissions(self, method="POST"):
+ """
+ Check both plugin write and NetBox object permissions.
+
+ Combines require_write_permission() and require_object_permissions()
+ into a single call. Handles HTMX and regular requests.
+
+ Returns:
+ None if permitted, or appropriate error response if denied
+ """
+ if error := self.require_write_permission():
+ return error
+ return self.require_object_permissions(method)
+
+ def require_all_permissions_json(self, method="POST"):
+ """
+ Check both plugin write and NetBox object permissions, returning JSON errors.
+
+ Combines require_write_permission_json() and require_object_permissions_json()
+ into a single call for JSON/AJAX endpoints.
+
+ Returns:
+ None if permitted, or JsonResponse with 403 status if denied
+ """
+ if error := self.require_write_permission_json():
+ return error
+ return self.require_object_permissions_json(method)
+
+
class LibreNMSAPIMixin:
"""
A mixin class that provides access to the LibreNMS API.
@@ -112,3 +304,378 @@ def get_last_fetched_key(self, obj, data_type="ports"):
"""
model_name = obj._meta.model_name
return f"librenms_{data_type}_last_fetched_{model_name}_{obj.pk}"
+
+ def get_vlan_overrides_key(self, obj):
+ """
+ Get the cache key for user VLAN group override selections.
+
+ Stores a {vid_str: group_id_str} map so that "apply to all" VLAN
+ group choices persist across table pages.
+ """
+ model_name = obj._meta.model_name
+ return f"librenms_vlan_group_overrides_{model_name}_{obj.pk}"
+
+
+class VlanAssignmentMixin:
+ """
+ Mixin providing VLAN assignment utilities for views.
+
+ Provides methods for:
+ - Getting relevant VLAN groups for a device based on scope hierarchy
+ - Building lookup maps for VLAN matching
+ - Selecting the most specific VLAN group based on device context
+ - Finding VLANs by VID within a specific group
+ - Updating interface VLAN assignments
+ """
+
+ def get_vlan_groups_for_device(self, device):
+ """
+ Get all VLAN groups relevant to this device.
+
+ Searches for VLAN groups scoped to:
+ - Site: The device's assigned site
+ - Location: The device's location and all parent locations
+ - Region: The device's site's region and all parent regions
+ - Site Group: The device's site's group and all parent site groups
+ - Rack: The device's rack
+ - Global: VLAN groups with no scope
+
+ Returns:
+ List of VLANGroup objects, deduplicated and sorted by name
+ """
+ from dcim.models import Location, Rack, Region, Site, SiteGroup
+ from ipam.models import VLANGroup
+
+ groups = set()
+
+ # Site-scoped VLAN groups
+ if hasattr(device, "site") and device.site:
+ site_groups = self._get_vlan_groups_for_scope(Site, [device.site])
+ groups.update(site_groups)
+
+ # Region-scoped VLAN groups (site's region and ancestors)
+ if device.site.region:
+ region_ancestors = self._get_ancestors(device.site.region)
+ region_groups = self._get_vlan_groups_for_scope(Region, region_ancestors)
+ groups.update(region_groups)
+
+ # Site Group-scoped VLAN groups (site's group and ancestors)
+ if device.site.group:
+ site_group_ancestors = self._get_ancestors(device.site.group)
+ site_group_groups = self._get_vlan_groups_for_scope(SiteGroup, site_group_ancestors)
+ groups.update(site_group_groups)
+
+ # Location-scoped VLAN groups (device's location and ancestors)
+ if hasattr(device, "location") and device.location:
+ location_ancestors = self._get_ancestors(device.location)
+ location_groups = self._get_vlan_groups_for_scope(Location, location_ancestors)
+ groups.update(location_groups)
+
+ # Rack-scoped VLAN groups
+ if hasattr(device, "rack") and device.rack:
+ rack_groups = self._get_vlan_groups_for_scope(Rack, [device.rack])
+ groups.update(rack_groups)
+
+ # Global VLAN groups (no scope)
+ global_groups = VLANGroup.objects.filter(scope_type__isnull=True)
+ groups.update(global_groups)
+
+ # Return sorted by name for consistent display
+ return sorted(groups, key=lambda g: g.name.lower())
+
+ def _build_vlan_lookup_maps(self, vlan_groups):
+ """
+ Build lookup dictionaries for VLAN matching.
+
+ Returns a dict with:
+ - vid_to_groups: {vid: [vlan_group, ...]} - VID to groups containing that VID
+ - vid_group_to_vlan: {(vid, group_id): vlan} - unique per group lookup
+ - vid_to_vlans: {vid: [vlan, ...]} - all VLANs with that VID
+ - vid_name_to_vlan: {(vid, name): vlan} - VID + name lookup
+ """
+ from ipam.models import VLAN
+
+ vid_to_groups = {}
+ vid_group_to_vlan = {}
+ vid_to_vlans = {}
+ vid_name_to_vlan = {}
+
+ # Get all VLANs from relevant groups and global VLANs
+ group_pks = [g.pk for g in vlan_groups]
+ vlans = VLAN.objects.filter(group__pk__in=group_pks).select_related("group")
+ # Also get global VLANs (no group)
+ global_vlans = VLAN.objects.filter(group__isnull=True)
+
+ for vlan in list(vlans) + list(global_vlans):
+ vid = vlan.vid
+ group = vlan.group
+ group_id = group.pk if group else None
+ name = vlan.name
+
+ # Build VID to groups lookup for ambiguity detection
+ if vid not in vid_to_groups:
+ vid_to_groups[vid] = []
+ if group and group not in vid_to_groups[vid]:
+ vid_to_groups[vid].append(group)
+
+ # Build (vid, group_id) to vlan lookup
+ vid_group_to_vlan[(vid, group_id)] = vlan
+
+ # Build VID to all VLANs list (for dropdown options)
+ if vid not in vid_to_vlans:
+ vid_to_vlans[vid] = []
+ vid_to_vlans[vid].append(vlan)
+
+ # Build (vid, name) to vlan lookup
+ vid_name_to_vlan[(vid, name)] = vlan
+
+ return {
+ "vid_to_groups": vid_to_groups,
+ "vid_group_to_vlan": vid_group_to_vlan,
+ "vid_to_vlans": vid_to_vlans,
+ "vid_name_to_vlan": vid_name_to_vlan,
+ }
+
+ def _select_most_specific_group(self, groups, device):
+ """
+ Select the most specific VLAN group based on device context.
+
+ Priority order (most specific to least specific):
+ 1. Rack-scoped (device's rack)
+ 2. Location-scoped (device's location, closer ancestors win)
+ 3. Site-scoped (device's site)
+ 4. Site Group-scoped (device's site's group, closer ancestors win)
+ 5. Region-scoped (device's site's region, closer ancestors win)
+ 6. Global (no scope)
+
+ Args:
+ groups: List of VLANGroup objects that all contain the same VID
+ device: NetBox Device object
+
+ Returns:
+ VLANGroup or None if no clear winner (e.g., multiple groups at same priority level)
+ """
+ from dcim.models import Location, Rack, Region, Site, SiteGroup
+ from django.contrib.contenttypes.models import ContentType
+
+ if not device or not groups:
+ return None
+
+ # Build scope priority lookup for this device
+ # Lower number = higher priority (more specific)
+ scope_priority = {}
+ priority = 0
+
+ # Priority 1: Rack (most specific)
+ if hasattr(device, "rack") and device.rack:
+ rack_ct = ContentType.objects.get_for_model(Rack)
+ scope_priority[(rack_ct.pk, device.rack.pk)] = priority
+ priority += 1
+
+ # Priority 2: Location hierarchy (device's location first, then ancestors)
+ if hasattr(device, "location") and device.location:
+ location_ct = ContentType.objects.get_for_model(Location)
+ for loc in self._get_ancestors(device.location):
+ scope_priority[(location_ct.pk, loc.pk)] = priority
+ priority += 1
+
+ # Priority 3: Site
+ if hasattr(device, "site") and device.site:
+ site_ct = ContentType.objects.get_for_model(Site)
+ scope_priority[(site_ct.pk, device.site.pk)] = priority
+ priority += 1
+
+ # Priority 4: Site Group hierarchy
+ if device.site.group:
+ site_group_ct = ContentType.objects.get_for_model(SiteGroup)
+ for sg in self._get_ancestors(device.site.group):
+ scope_priority[(site_group_ct.pk, sg.pk)] = priority
+ priority += 1
+
+ # Priority 5: Region hierarchy
+ if device.site.region:
+ region_ct = ContentType.objects.get_for_model(Region)
+ for reg in self._get_ancestors(device.site.region):
+ scope_priority[(region_ct.pk, reg.pk)] = priority
+ priority += 1
+
+ # Priority 6: Global (no scope) - lowest priority
+ global_priority = priority
+
+ # Find the group with the highest priority (lowest number)
+ best_group = None
+ best_priority = float("inf")
+ same_priority_count = 0
+
+ for group in groups:
+ if group.scope_type is None:
+ # Global scope
+ group_priority = global_priority
+ else:
+ scope_key = (group.scope_type.pk, group.scope_id)
+ group_priority = scope_priority.get(scope_key, float("inf"))
+
+ if group_priority < best_priority:
+ best_priority = group_priority
+ best_group = group
+ same_priority_count = 1
+ elif group_priority == best_priority:
+ same_priority_count += 1
+
+ # Only return a group if there's a single winner at the best priority level
+ if same_priority_count == 1 and best_group is not None:
+ return best_group
+
+ return None
+
+ def _get_ancestors(self, obj):
+ """
+ Get all ancestors of a hierarchical object (location, region, site group).
+ Returns list including the object itself and all parents up to root.
+ """
+ ancestors = []
+ current = obj
+ while current is not None:
+ ancestors.append(current)
+ current = getattr(current, "parent", None)
+ return ancestors
+
+ def _get_vlan_groups_for_scope(self, model_class, objects):
+ """
+ Get VLAN groups scoped to any of the given objects.
+
+ Args:
+ model_class: The Django model class (Site, Location, Region, etc.)
+ objects: List of model instances to check
+
+ Returns:
+ QuerySet of VLANGroup objects
+ """
+ from django.contrib.contenttypes.models import ContentType
+ from ipam.models import VLANGroup
+
+ if not objects:
+ return VLANGroup.objects.none()
+
+ content_type = ContentType.objects.get_for_model(model_class)
+ object_ids = [obj.pk for obj in objects if obj is not None]
+
+ if not object_ids:
+ return VLANGroup.objects.none()
+
+ return VLANGroup.objects.filter(scope_type=content_type, scope_id__in=object_ids)
+
+ def _find_vlan_in_group(self, vid, vlan_group_id, lookup_maps):
+ """
+ Find a VLAN by VID, preferring the specified group.
+
+ Args:
+ vid: VLAN ID (integer)
+ vlan_group_id: Optional VLAN group ID to prefer
+ lookup_maps: Dict from _build_vlan_lookup_maps()
+
+ Returns:
+ VLAN object or None
+ """
+ vid_group_to_vlan = lookup_maps.get("vid_group_to_vlan", {})
+ vid_to_vlans = lookup_maps.get("vid_to_vlans", {})
+
+ # Try specific group first
+ if vlan_group_id:
+ try:
+ vlan = vid_group_to_vlan.get((vid, int(vlan_group_id)))
+ if vlan:
+ return vlan
+ except (ValueError, TypeError):
+ pass
+
+ # Try global (no group)
+ vlan = vid_group_to_vlan.get((vid, None))
+ if vlan:
+ return vlan
+
+ # Fallback: first matching VLAN
+ vlans = vid_to_vlans.get(vid, [])
+ return vlans[0] if vlans else None
+
+ def _update_interface_vlan_assignment(self, interface, vlan_data, vlan_group_map, lookup_maps):
+ """
+ Update interface VLAN assignments in NetBox (mode, untagged_vlan, tagged_vlans).
+
+ Args:
+ interface: NetBox Interface or VMInterface object
+ vlan_data: Dict with 'untagged_vlan' (int or None) and 'tagged_vlans' (list of ints)
+ vlan_group_map: Dict mapping VID (str) to VLAN group ID for per-VLAN group lookups.
+ Can also be a single group ID string for backward compat.
+ lookup_maps: Dict from _build_vlan_lookup_maps()
+
+ Returns:
+ Dict with sync results:
+ - mode_set: str or None
+ - untagged_set: VLAN object or None
+ - tagged_set: list of VLAN objects
+ - missing_vlans: list of VIDs not found in NetBox
+ """
+ # Support both dict (per-VLAN) and string/int/None (single group) for backward compat
+ if not isinstance(vlan_group_map, dict):
+ single_group_id = vlan_group_map
+ vlan_group_map = None
+ else:
+ single_group_id = None
+
+ untagged_vid = vlan_data.get("untagged_vlan")
+ tagged_vids = vlan_data.get("tagged_vlans", [])
+ missing_vlans = []
+
+ def _get_group_id_for_vid(vid):
+ """Resolve the VLAN group ID for a specific VID."""
+ if vlan_group_map is not None:
+ return vlan_group_map.get(str(vid), "")
+ return single_group_id or ""
+
+ # Determine mode
+ if tagged_vids:
+ interface.mode = "tagged"
+ elif untagged_vid:
+ interface.mode = "access"
+ else:
+ # No VLANs - clear mode
+ interface.mode = ""
+
+ # Set untagged VLAN
+ untagged_set = None
+ if untagged_vid:
+ vlan = self._find_vlan_in_group(untagged_vid, _get_group_id_for_vid(untagged_vid), lookup_maps)
+ if vlan:
+ interface.untagged_vlan = vlan
+ untagged_set = vlan
+ else:
+ missing_vlans.append(untagged_vid)
+ interface.untagged_vlan = None
+ else:
+ interface.untagged_vlan = None
+
+ # Save mode + untagged_vlan before M2M operations.
+ # tagged_vlans.set() triggers a DB refresh that wipes unsaved
+ # in-memory attributes, so we must persist first.
+ interface.save()
+
+ # Set tagged VLANs (M2M - requires the instance to be saved first)
+ tagged_set = []
+ if tagged_vids:
+ for vid in tagged_vids:
+ vlan = self._find_vlan_in_group(vid, _get_group_id_for_vid(vid), lookup_maps)
+ if vlan:
+ tagged_set.append(vlan)
+ else:
+ missing_vlans.append(vid)
+ interface.tagged_vlans.set(tagged_set)
+ else:
+ interface.tagged_vlans.clear()
+
+ return {
+ "mode_set": interface.mode,
+ "untagged_set": untagged_set,
+ "tagged_set": tagged_set,
+ "missing_vlans": missing_vlans,
+ }
diff --git a/netbox_librenms_plugin/views/object_sync/__init__.py b/netbox_librenms_plugin/views/object_sync/__init__.py
index 365e1f1634..e9893cf7d8 100644
--- a/netbox_librenms_plugin/views/object_sync/__init__.py
+++ b/netbox_librenms_plugin/views/object_sync/__init__.py
@@ -5,7 +5,11 @@
DeviceInterfaceTableView,
DeviceIPAddressTableView,
DeviceLibreNMSSyncView,
+ DeviceVLANTableView,
+ SaveVlanGroupOverridesView,
SingleInterfaceVerifyView,
+ SingleVlanGroupVerifyView,
+ VerifyVlanSyncGroupView,
)
from .vms import ( # noqa: F401
VMInterfaceTableView,
diff --git a/netbox_librenms_plugin/views/object_sync/devices.py b/netbox_librenms_plugin/views/object_sync/devices.py
index 79880658da..97584f8319 100644
--- a/netbox_librenms_plugin/views/object_sync/devices.py
+++ b/netbox_librenms_plugin/views/object_sync/devices.py
@@ -8,6 +8,7 @@
from django.views import View
from utilities.views import ViewTab, register_model_view
+from netbox_librenms_plugin.constants import PERM_VIEW_PLUGIN
from netbox_librenms_plugin.tables.cables import (
LibreNMSCableTable,
VCCableTable,
@@ -16,13 +17,20 @@
LibreNMSInterfaceTable,
VCInterfaceTable,
)
-from netbox_librenms_plugin.utils import get_interface_name_field
+from netbox_librenms_plugin.utils import (
+ get_interface_name_field,
+ get_missing_vlan_warning,
+ get_tagged_vlan_css_class,
+ get_untagged_vlan_css_class,
+ get_vlan_sync_css_class,
+)
from ..base.cables_view import BaseCableTableView
from ..base.interfaces_view import BaseInterfaceTableView
from ..base.ip_addresses_view import BaseIPAddressTableView
from ..base.librenms_sync_view import BaseLibreNMSSyncView
-from ..mixins import CacheMixin
+from ..base.vlan_table_view import BaseVLANTableView
+from ..mixins import CacheMixin, LibreNMSPermissionMixin
@register_model_view(Device, name="librenms_sync", path="librenms-sync")
@@ -31,22 +39,30 @@ class DeviceLibreNMSSyncView(BaseLibreNMSSyncView):
queryset = Device.objects.all()
model = Device
- tab = ViewTab(label="LibreNMS Sync", permission="dcim.view_device")
+ tab = ViewTab(label="LibreNMS Sync", permission=PERM_VIEW_PLUGIN)
def get_interface_context(self, request, obj):
+ """Return interface sync context for the device."""
interface_name_field = get_interface_name_field(request)
interface_table_view = DeviceInterfaceTableView()
interface_table_view.request = request
return interface_table_view.get_context_data(request, obj, interface_name_field)
def get_cable_context(self, request, obj):
+ """Return cable sync context for the device."""
cable_table_view = DeviceCableTableView()
return cable_table_view.get_context_data(request, obj)
def get_ip_context(self, request, obj):
+ """Return IP address sync context for the device."""
ipaddress_table_view = DeviceIPAddressTableView()
return ipaddress_table_view.get_context_data(request, obj)
+ def get_vlan_context(self, request, obj):
+ vlan_table_view = DeviceVLANTableView()
+ vlan_table_view.request = request
+ return vlan_table_view.get_vlan_context(request, obj)
+
class DeviceInterfaceTableView(BaseInterfaceTableView):
"""Interface synchronization table for Devices."""
@@ -54,24 +70,32 @@ class DeviceInterfaceTableView(BaseInterfaceTableView):
model = Device
def get_interfaces(self, obj):
+ """Return all interfaces for the device."""
return obj.interfaces.all()
def get_redirect_url(self, obj):
- return reverse("plugins:netbox_librenms_plugin:vm_interface_sync", kwargs={"pk": obj.pk})
+ """Return the device interface sync redirect URL."""
+ return reverse("plugins:netbox_librenms_plugin:device_interface_sync", kwargs={"pk": obj.pk})
- def get_table(self, data, obj, interface_name_field):
+ def get_table(self, data, obj, interface_name_field, vlan_groups=None):
+ """Return the appropriate interface table, selecting VC variant if needed."""
if hasattr(obj, "virtual_chassis") and obj.virtual_chassis:
- table = VCInterfaceTable(data, device=obj, interface_name_field=interface_name_field)
+ table = VCInterfaceTable(
+ data, device=obj, interface_name_field=interface_name_field, vlan_groups=vlan_groups
+ )
else:
- table = LibreNMSInterfaceTable(data, device=obj, interface_name_field=interface_name_field)
+ table = LibreNMSInterfaceTable(
+ data, device=obj, interface_name_field=interface_name_field, vlan_groups=vlan_groups
+ )
table.htmx_url = f"{self.request.path}?tab=interfaces"
return table
-class SingleInterfaceVerifyView(CacheMixin, View):
+class SingleInterfaceVerifyView(LibreNMSPermissionMixin, CacheMixin, View):
"""Verify single interface data for a device via cached LibreNMS payload."""
def post(self, request):
+ """Verify interface data against cached LibreNMS ports for a device."""
data = json.loads(request.body)
selected_device_id = data.get("device_id")
interface_name = data.get("interface_name")
@@ -113,12 +137,229 @@ def post(self, request):
return JsonResponse({"status": "error", "message": "Interface data not found"}, status=404)
+class SingleVlanGroupVerifyView(LibreNMSPermissionMixin, CacheMixin, View):
+ """
+ Verify VLAN assignments for an interface against a specific VLAN group.
+
+ When user changes the VLAN group dropdown, this endpoint re-computes
+ which VLANs are "missing" (don't exist in selected group) and returns
+ updated HTML for the VLANs cell with correct colors.
+ """
+
+ def post(self, request):
+ from ipam.models import VLAN, VLANGroup
+
+ data = json.loads(request.body)
+ device_id = data.get("device_id")
+ interface_name = data.get("interface_name")
+ vlan_group_id = data.get("vlan_group_id")
+ vlan_type = data.get("vlan_type", "U") # "U" or "T"
+ vid_str = data.get("vid", "") or data.get("untagged_vlan", "")
+
+ if not device_id:
+ return JsonResponse({"status": "error", "message": "No device ID provided"}, status=400)
+ if not vid_str:
+ return JsonResponse({"status": "error", "message": "No VID provided"}, status=400)
+
+ device = get_object_or_404(Device, pk=device_id)
+ try:
+ vid = int(vid_str)
+ except (ValueError, TypeError):
+ return JsonResponse({"status": "error", "message": "Invalid VID"}, status=400)
+
+ # Build lookup for the selected group
+ if vlan_group_id:
+ vlan_group = get_object_or_404(VLANGroup, pk=vlan_group_id)
+ # Get VLANs in selected group + global VLANs
+ group_vids = set(VLAN.objects.filter(group=vlan_group).values_list("vid", flat=True))
+ global_vids = set(VLAN.objects.filter(group__isnull=True).values_list("vid", flat=True))
+ available_vids = group_vids | global_vids
+ else:
+ # No group selected - use global VLANs only
+ available_vids = set(VLAN.objects.filter(group__isnull=True).values_list("vid", flat=True))
+
+ # Compute whether VID is missing from selected group
+ is_missing = vid not in available_vids
+ missing_vlans = [vid] if is_missing else []
+
+ # Get NetBox interface for comparison
+ netbox_interface = device.interfaces.filter(name=interface_name).first()
+ exists_in_netbox = bool(netbox_interface)
+
+ # Get NetBox VLAN assignments (VID + group for group-aware comparison)
+ netbox_untagged_vid = None
+ netbox_untagged_group_id = None
+ netbox_tagged_vids = set()
+ netbox_tagged_group_ids = {}
+ if netbox_interface:
+ if netbox_interface.untagged_vlan:
+ netbox_untagged_vid = netbox_interface.untagged_vlan.vid
+ netbox_untagged_group_id = netbox_interface.untagged_vlan.group_id
+ for v in netbox_interface.tagged_vlans.all():
+ netbox_tagged_vids.add(v.vid)
+ netbox_tagged_group_ids[v.vid] = v.group_id
+
+ # Determine group match: selected group vs NetBox VLAN's actual group
+ selected_gid = int(vlan_group_id) if vlan_group_id else None
+
+ # Determine CSS class based on actual VLAN type
+ if vlan_type == "U":
+ # Group matches only matters when VIDs match
+ group_matches = (netbox_untagged_group_id == selected_gid) if netbox_untagged_vid == vid else True
+ css_class = get_untagged_vlan_css_class(
+ vid, netbox_untagged_vid, exists_in_netbox, missing_vlans, group_matches
+ )
+ else:
+ netbox_gid = netbox_tagged_group_ids.get(vid)
+ group_matches = (netbox_gid == selected_gid) if vid in netbox_tagged_vids else True
+ css_class = get_tagged_vlan_css_class(
+ vid, netbox_tagged_vids, exists_in_netbox, missing_vlans, group_matches
+ )
+
+ # Also render formatted HTML for backward compatibility
+ formatted_vlans = self._render_vlans_cell(
+ vid if vlan_type == "U" else None,
+ [vid] if vlan_type == "T" else [],
+ missing_vlans,
+ exists_in_netbox,
+ netbox_untagged_vid,
+ netbox_tagged_vids,
+ )
+
+ return JsonResponse(
+ {
+ "status": "success",
+ "formatted_vlans": formatted_vlans,
+ "css_class": css_class,
+ "is_missing": is_missing,
+ }
+ )
+
+ def _render_vlans_cell(
+ self, untagged, tagged, missing_vlans, exists_in_netbox, netbox_untagged_vid, netbox_tagged_vids
+ ):
+ """
+ Render the VLANs cell HTML with correct color coding.
+
+ Reuses the same color logic as LibreNMSInterfaceTable.render_vlans().
+ """
+ from django.utils.safestring import mark_safe
+
+ parts = []
+
+ if untagged:
+ css = get_untagged_vlan_css_class(untagged, netbox_untagged_vid, exists_in_netbox, missing_vlans)
+ warning = get_missing_vlan_warning(untagged, missing_vlans)
+ parts.append(f'{untagged}(U){warning}')
+
+ for vid in sorted(tagged):
+ css = get_tagged_vlan_css_class(vid, netbox_tagged_vids, exists_in_netbox, missing_vlans)
+ warning = get_missing_vlan_warning(vid, missing_vlans)
+ parts.append(f'{vid}(T){warning}')
+
+ if not parts:
+ return "β"
+
+ return mark_safe(", ".join(parts))
+
+
+class VerifyVlanSyncGroupView(LibreNMSPermissionMixin, View):
+ """
+ Verify whether a VLAN (by VID) exists in a selected VLAN group.
+
+ Called from the VLAN sync tab when the user changes the per-row
+ VLAN group dropdown. Returns the correct CSS class so the JS can
+ update row colors without a full page reload.
+ """
+
+ def post(self, request):
+ from ipam.models import VLAN, VLANGroup
+
+ data = json.loads(request.body)
+ vlan_group_id = data.get("vlan_group_id")
+ vid_str = data.get("vid", "")
+ librenms_name = data.get("name", "")
+
+ if not vid_str:
+ return JsonResponse({"status": "error", "message": "No VID provided"}, status=400)
+
+ try:
+ vid = int(vid_str)
+ except (ValueError, TypeError):
+ return JsonResponse({"status": "error", "message": "Invalid VID"}, status=400)
+
+ # Check if VLAN exists in the selected group (or globally)
+ if vlan_group_id:
+ vlan_group = get_object_or_404(VLANGroup, pk=vlan_group_id)
+ netbox_vlan = VLAN.objects.filter(vid=vid, group=vlan_group).first()
+ else:
+ # No group = global VLANs
+ netbox_vlan = VLAN.objects.filter(vid=vid, group__isnull=True).first()
+
+ exists_in_netbox = bool(netbox_vlan)
+ name_matches = netbox_vlan.name == librenms_name if netbox_vlan else False
+ css_class = get_vlan_sync_css_class(exists_in_netbox, name_matches)
+
+ return JsonResponse(
+ {
+ "status": "success",
+ "exists_in_netbox": exists_in_netbox,
+ "name_matches": name_matches,
+ "css_class": css_class,
+ "netbox_vlan_name": netbox_vlan.name if netbox_vlan else None,
+ }
+ )
+
+
+class SaveVlanGroupOverridesView(LibreNMSPermissionMixin, CacheMixin, View):
+ """
+ Persist user VLAN-group-override selections in cache.
+
+ When the user edits VLAN group assignments in the modal and checks
+ "Apply to all interfaces", the JS posts the {vid: group_id} map here
+ so that subsequent table pages render with the same choices.
+ The overrides are stored with the same remaining TTL as the ports
+ cache so they expire together.
+ """
+
+ def post(self, request):
+ # Require plugin write permission to persist VLAN group overrides
+ if error := self.require_write_permission_json():
+ return error
+
+ data = json.loads(request.body)
+ device_id = data.get("device_id")
+ vid_group_map = data.get("vid_group_map", {})
+
+ if not device_id:
+ return JsonResponse({"status": "error", "message": "No device ID provided"}, status=400)
+
+ device = get_object_or_404(Device, pk=device_id)
+
+ # Use the remaining TTL of the ports cache so both expire together
+ ports_ttl = cache.ttl(self.get_cache_key(device, "ports"))
+ if ports_ttl is None or ports_ttl <= 0:
+ return JsonResponse(
+ {"status": "error", "message": "No cached port data; refresh interfaces first"},
+ status=400,
+ )
+
+ # Merge with any existing overrides (user may save multiple times)
+ existing = cache.get(self.get_vlan_overrides_key(device)) or {}
+ existing.update(vid_group_map)
+
+ cache.set(self.get_vlan_overrides_key(device), existing, timeout=ports_ttl)
+
+ return JsonResponse({"status": "success"})
+
+
class DeviceCableTableView(BaseCableTableView):
"""Cable synchronization view for Devices."""
model = Device
def get_table(self, data, obj):
+ """Return the appropriate cable table, selecting VC variant if needed."""
if hasattr(obj, "virtual_chassis") and obj.virtual_chassis:
return VCCableTable(data, device=obj)
return LibreNMSCableTable(data, device=obj)
@@ -128,3 +369,9 @@ class DeviceIPAddressTableView(BaseIPAddressTableView):
"""IP address synchronization view for Devices."""
model = Device
+
+
+class DeviceVLANTableView(BaseVLANTableView):
+ """VLAN synchronization table view for Devices."""
+
+ model = Device
diff --git a/netbox_librenms_plugin/views/object_sync/vms.py b/netbox_librenms_plugin/views/object_sync/vms.py
index 3ce126837f..51143d909d 100644
--- a/netbox_librenms_plugin/views/object_sync/vms.py
+++ b/netbox_librenms_plugin/views/object_sync/vms.py
@@ -2,6 +2,7 @@
from utilities.views import ViewTab, register_model_view
from virtualization.models import VirtualMachine
+from netbox_librenms_plugin.constants import PERM_VIEW_PLUGIN
from netbox_librenms_plugin.tables.interfaces import LibreNMSVMInterfaceTable
from netbox_librenms_plugin.utils import get_interface_name_field
@@ -18,18 +19,21 @@ class VMLibreNMSSyncView(BaseLibreNMSSyncView):
model = VirtualMachine
tab = ViewTab(
label="LibreNMS Sync",
- permission="virtualization.view_virtualmachine",
+ permission=PERM_VIEW_PLUGIN,
)
def get_interface_context(self, request, obj):
+ """Return interface sync context for the virtual machine."""
interface_name_field = get_interface_name_field(request)
interface_sync_view = VMInterfaceTableView()
return interface_sync_view.get_context_data(request, obj, interface_name_field)
def get_cable_context(self, request, obj):
+ """Return None; VMs do not support cable sync."""
return None # VMs do not expose cable sync data
def get_ip_context(self, request, obj):
+ """Return IP address sync context for the virtual machine."""
ipaddress_sync_view = VMIPAddressTableView()
return ipaddress_sync_view.get_context_data(request, obj)
@@ -39,13 +43,16 @@ class VMInterfaceTableView(BaseInterfaceTableView):
model = VirtualMachine
- def get_table(self, data, obj, interface_name_field):
- return LibreNMSVMInterfaceTable(data)
+ def get_table(self, data, obj, interface_name_field, vlan_groups=None):
+ """Return a VM interface table for the given data."""
+ return LibreNMSVMInterfaceTable(data, device=obj, vlan_groups=vlan_groups)
def get_interfaces(self, obj):
+ """Return all interfaces for the virtual machine."""
return obj.interfaces.all()
def get_redirect_url(self, obj):
+ """Return the VM interface sync redirect URL."""
return reverse("plugins:netbox_librenms_plugin:vm_interface_sync", kwargs={"pk": obj.pk})
diff --git a/netbox_librenms_plugin/views/settings_views.py b/netbox_librenms_plugin/views/settings_views.py
index 6bcbf47b28..a6a7585b8f 100644
--- a/netbox_librenms_plugin/views/settings_views.py
+++ b/netbox_librenms_plugin/views/settings_views.py
@@ -1,21 +1,26 @@
+import logging
+
from django.contrib import messages
-from django.contrib.auth.mixins import PermissionRequiredMixin
from django.http import HttpResponse
from django.shortcuts import redirect, render
+from django.utils.html import escape
from django.views import View
from netbox_librenms_plugin.forms import ImportSettingsForm, ServerConfigForm
from netbox_librenms_plugin.librenms_api import LibreNMSAPI
from netbox_librenms_plugin.models import LibreNMSSettings
+from netbox_librenms_plugin.utils import save_user_pref
+from netbox_librenms_plugin.views.mixins import LibreNMSPermissionMixin
+
+logger = logging.getLogger(__name__)
-class LibreNMSSettingsView(PermissionRequiredMixin, View):
+class LibreNMSSettingsView(LibreNMSPermissionMixin, View):
"""
View for managing plugin settings including server selection and import options.
Uses two separate forms for cleaner validation and separation of concerns.
"""
- permission_required = "netbox_librenms_plugin.change_librenmssettings"
template_name = "netbox_librenms_plugin/settings.html"
def get(self, request):
@@ -39,6 +44,10 @@ def get(self, request):
def post(self, request):
"""Handle form submission - process the appropriate form based on form_type."""
+ # Check write permission for POST actions
+ if error := self.require_write_permission():
+ return error
+
# Get or create the settings object
settings, created = LibreNMSSettings.objects.get_or_create()
@@ -65,6 +74,30 @@ def post(self, request):
if import_form.is_valid():
import_form.save()
+ # Also update current user's preferences to match new defaults
+ try:
+ save_user_pref(
+ request,
+ "plugins.netbox_librenms_plugin.use_sysname",
+ import_form.cleaned_data.get("use_sysname_default", False),
+ )
+ save_user_pref(
+ request,
+ "plugins.netbox_librenms_plugin.strip_domain",
+ import_form.cleaned_data.get("strip_domain_default", False),
+ )
+ except (TypeError, ValueError) as e:
+ logger.warning(
+ "Failed to update user preferences due to invalid value: %s (user: %s)",
+ e,
+ request.user,
+ )
+ except Exception as e:
+ logger.exception(
+ "Unexpected error while updating user preferences for user %s: %s",
+ request.user,
+ e,
+ )
messages.success(
request,
"Import settings updated successfully.",
@@ -89,7 +122,7 @@ def post(self, request):
)
-class TestLibreNMSConnectionView(View):
+class TestLibreNMSConnectionView(LibreNMSPermissionMixin, View):
"""
HTMX view to test LibreNMS server connection.
Returns HTML fragment instead of JSON for HTMX compatibility.
@@ -115,9 +148,9 @@ def post(self, request):
system_info = api_client.test_connection()
if system_info and not system_info.get("error"):
- version = system_info.get("local_ver", "Unknown")
- database = system_info.get("database_ver", "Unknown")
- php_version = system_info.get("php_ver", "Unknown")
+ version = escape(system_info.get("local_ver", "Unknown"))
+ database = escape(system_info.get("database_ver", "Unknown"))
+ php_version = escape(system_info.get("php_ver", "Unknown"))
return HttpResponse(
f''
@@ -129,7 +162,7 @@ def post(self, request):
f"
"
)
elif system_info and system_info.get("error"):
- error_msg = system_info.get("message", "Unknown error occurred")
+ error_msg = escape(system_info.get("message", "Unknown error occurred"))
return HttpResponse(
f''
f'
'
@@ -149,13 +182,13 @@ def post(self, request):
return HttpResponse(
f'
'
f''
- f"Configuration error:
{str(e)}"
+ f"Configuration error:
{escape(str(e))}"
f"
"
)
except Exception as e:
return HttpResponse(
f'
'
f''
- f"Connection failed:
{str(e)}"
+ f"Connection failed:
{escape(str(e))}"
f"
"
)
diff --git a/netbox_librenms_plugin/views/status_check.py b/netbox_librenms_plugin/views/status_check.py
index ffbf6d74f4..bce8fe963d 100644
--- a/netbox_librenms_plugin/views/status_check.py
+++ b/netbox_librenms_plugin/views/status_check.py
@@ -12,12 +12,12 @@
)
from netbox_librenms_plugin.tables.device_status import DeviceStatusTable
from netbox_librenms_plugin.tables.VM_status import VMStatusTable
-from netbox_librenms_plugin.views.mixins import LibreNMSAPIMixin
+from netbox_librenms_plugin.views.mixins import LibreNMSAPIMixin, LibreNMSPermissionMixin
logger = logging.getLogger(__name__)
-class DeviceStatusListView(LibreNMSAPIMixin, generic.ObjectListView):
+class DeviceStatusListView(LibreNMSPermissionMixin, LibreNMSAPIMixin, generic.ObjectListView):
"""
Check the status of NetBox devices in LibreNMS.
Shows NetBox devices with their LibreNMS status.
@@ -71,7 +71,7 @@ def get_queryset(self, request):
return Device.objects.none()
-class VMStatusListView(LibreNMSAPIMixin, generic.ObjectListView):
+class VMStatusListView(LibreNMSPermissionMixin, LibreNMSAPIMixin, generic.ObjectListView):
"""
Check the status of virtual machines in NetBox against LibreNMS
"""
@@ -85,6 +85,7 @@ class VMStatusListView(LibreNMSAPIMixin, generic.ObjectListView):
title = "Virtual Machine LibreNMS Status"
def get_queryset(self, request):
+ """Return VMs annotated with their LibreNMS status."""
if self.request.GET:
queryset = VirtualMachine.objects.select_related("cluster", "site")
diff --git a/netbox_librenms_plugin/views/sync/cables.py b/netbox_librenms_plugin/views/sync/cables.py
index 2b9ec4b9b3..0e52f3b017 100644
--- a/netbox_librenms_plugin/views/sync/cables.py
+++ b/netbox_librenms_plugin/views/sync/cables.py
@@ -7,13 +7,21 @@
from django.urls import reverse
from django.views import View
-from netbox_librenms_plugin.views.mixins import CacheMixin
+from netbox_librenms_plugin.views.mixins import CacheMixin, LibreNMSPermissionMixin, NetBoxObjectPermissionMixin
-class SyncCablesView(CacheMixin, View):
+class SyncCablesView(LibreNMSPermissionMixin, NetBoxObjectPermissionMixin, CacheMixin, View):
"""Create NetBox cables using cached LibreNMS link data."""
+ required_object_permissions = {
+ "POST": [
+ ("add", Cable),
+ ("change", Cable),
+ ],
+ }
+
def get_selected_interfaces(self, request, initial_device):
+ """Return selected interface entries from POST data."""
selected_interfaces = []
selected_data = [x for x in request.POST.getlist("select") if x]
@@ -27,27 +35,33 @@ def get_selected_interfaces(self, request, initial_device):
return selected_interfaces
def get_cached_links_data(self, request, obj):
+ """Return cached LibreNMS link data for the given object."""
cached_data = cache.get(self.get_cache_key(obj, "links"))
if not cached_data:
return None
return cached_data.get("links", [])
def create_cable(self, local_interface, remote_interface, request):
+ """Create a cable between local and remote interfaces."""
try:
Cable.objects.create(
a_terminations=[local_interface],
b_terminations=[remote_interface],
status="connected",
)
+ return True
except Exception as exc: # pragma: no cover - protects UX
messages.error(request, f"Failed to create cable: {str(exc)}")
+ return False
def check_existing_cable(self, local_interface, remote_interface):
+ """Return True if a cable already exists for either interface."""
return Cable.objects.filter(
Q(terminations__termination_id=local_interface.pk) | Q(terminations__termination_id=remote_interface.pk)
).exists()
def validate_prerequisites(self, cached_links, selected_interfaces):
+ """Validate that cached data and selections are present before sync."""
if not cached_links:
messages.error(
self.request,
@@ -62,6 +76,7 @@ def validate_prerequisites(self, cached_links, selected_interfaces):
return True
def process_single_interface(self, interface, cached_links):
+ """Process cable creation for a single interface from cached link data."""
try:
link_data = next(link for link in cached_links if link["local_port"] == interface["interface"])
return self.handle_cable_creation(link_data, interface)
@@ -69,6 +84,7 @@ def process_single_interface(self, interface, cached_links):
return {"status": "invalid"}
def verify_cable_creation_requirements(self, link_data):
+ """Return True if all required NetBox IDs are present in link data."""
required_fields = [
"netbox_local_interface_id",
"netbox_remote_device_id",
@@ -78,6 +94,7 @@ def verify_cable_creation_requirements(self, link_data):
return all(link_data.get(field) for field in required_fields)
def handle_cable_creation(self, link_data, interface):
+ """Create a cable from link data and return the operation result."""
if not self.verify_cable_creation_requirements(link_data):
return {"status": "invalid", "interface": interface["interface"]}
@@ -88,13 +105,15 @@ def handle_cable_creation(self, link_data, interface):
if self.check_existing_cable(local_interface, remote_interface):
return {"status": "duplicate", "interface": interface["interface"]}
- self.create_cable(local_interface, remote_interface, self.request)
- return {"status": "valid", "interface": interface["interface"]}
+ if self.create_cable(local_interface, remote_interface, self.request):
+ return {"status": "valid", "interface": interface["interface"]}
+ return {"status": "invalid", "interface": interface["interface"]} # pragma: no cover
except Interface.DoesNotExist:
return {"status": "missing_remote", "interface": interface["interface"]}
def process_interface_sync(self, selected_interfaces, cached_links):
+ """Process cable sync for all selected interfaces and return results."""
results = {"valid": [], "invalid": [], "duplicate": [], "missing_remote": []}
with transaction.atomic():
@@ -105,6 +124,11 @@ def process_interface_sync(self, selected_interfaces, cached_links):
return results
def post(self, request, pk):
+ """Sync selected cable connections from LibreNMS into NetBox."""
+ # Check both plugin write and NetBox object permissions
+ if error := self.require_all_permissions("POST"):
+ return error
+
initial_device = get_object_or_404(Device, pk=pk)
selected_interfaces = self.get_selected_interfaces(request, initial_device)
cached_links = self.get_cached_links_data(request, initial_device)
@@ -122,6 +146,7 @@ def post(self, request, pk):
)
def display_sync_results(self, request, results):
+ """Display flash messages summarizing the cable sync results."""
if results["missing_remote"]:
messages.error(
request,
diff --git a/netbox_librenms_plugin/views/sync/device_fields.py b/netbox_librenms_plugin/views/sync/device_fields.py
index c424f0392b..952afdefa9 100644
--- a/netbox_librenms_plugin/views/sync/device_fields.py
+++ b/netbox_librenms_plugin/views/sync/device_fields.py
@@ -1,16 +1,27 @@
from dcim.models import Device, Manufacturer, Platform
from django.contrib import messages
+from django.core.exceptions import ValidationError
+from django.db import IntegrityError
from django.shortcuts import get_object_or_404, redirect
from django.views import View
from netbox_librenms_plugin.utils import match_librenms_hardware_to_device_type
-from netbox_librenms_plugin.views.mixins import LibreNMSAPIMixin
+from netbox_librenms_plugin.views.mixins import LibreNMSAPIMixin, LibreNMSPermissionMixin, NetBoxObjectPermissionMixin
-class UpdateDeviceSerialView(LibreNMSAPIMixin, View):
+class UpdateDeviceSerialView(LibreNMSPermissionMixin, NetBoxObjectPermissionMixin, LibreNMSAPIMixin, View):
"""Update NetBox device serial number from LibreNMS."""
+ required_object_permissions = {
+ "POST": [("change", Device)],
+ }
+
def post(self, request, pk):
+ """Sync the device serial number from LibreNMS."""
+ # Check both plugin write and NetBox object permissions
+ if error := self.require_all_permissions("POST"):
+ return error
+
device = get_object_or_404(Device, pk=pk)
self.librenms_id = self.librenms_api.get_librenms_id(device)
@@ -32,7 +43,14 @@ def post(self, request, pk):
old_serial = device.serial
device.serial = serial
- device.save()
+ try:
+ device.full_clean()
+ device.save()
+ except (ValidationError, IntegrityError) as e:
+ device.serial = old_serial
+ error_msg = e.message_dict if hasattr(e, "message_dict") else str(e)
+ messages.error(request, f"Failed to update serial to '{serial}': {error_msg}")
+ return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk)
if old_serial:
messages.success(
@@ -45,10 +63,19 @@ def post(self, request, pk):
return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk)
-class UpdateDeviceTypeView(LibreNMSAPIMixin, View):
+class UpdateDeviceTypeView(LibreNMSPermissionMixin, NetBoxObjectPermissionMixin, LibreNMSAPIMixin, View):
"""Update NetBox DeviceType using LibreNMS hardware metadata."""
+ required_object_permissions = {
+ "POST": [("change", Device)],
+ }
+
def post(self, request, pk):
+ """Sync the device type from LibreNMS hardware info."""
+ # Check both plugin write and NetBox object permissions
+ if error := self.require_all_permissions("POST"):
+ return error
+
device = get_object_or_404(Device, pk=pk)
self.librenms_id = self.librenms_api.get_librenms_id(device)
@@ -80,7 +107,14 @@ def post(self, request, pk):
device_type = match_result["device_type"]
old_device_type = device.device_type
device.device_type = device_type
- device.save()
+ try:
+ device.full_clean()
+ device.save()
+ except (ValidationError, IntegrityError) as e:
+ device.device_type = old_device_type
+ error_msg = e.message_dict if hasattr(e, "message_dict") else str(e)
+ messages.error(request, f"Failed to update device type to '{device_type}': {error_msg}")
+ return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk)
messages.success(
request,
@@ -90,10 +124,19 @@ def post(self, request, pk):
return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk)
-class UpdateDevicePlatformView(LibreNMSAPIMixin, View):
+class UpdateDevicePlatformView(LibreNMSPermissionMixin, NetBoxObjectPermissionMixin, LibreNMSAPIMixin, View):
"""Update NetBox Platform based on LibreNMS OS info."""
+ required_object_permissions = {
+ "POST": [("change", Device)],
+ }
+
def post(self, request, pk):
+ """Sync the device platform from LibreNMS OS name."""
+ # Check both plugin write and NetBox object permissions
+ if error := self.require_all_permissions("POST"):
+ return error
+
device = get_object_or_404(Device, pk=pk)
self.librenms_id = self.librenms_api.get_librenms_id(device)
@@ -128,7 +171,14 @@ def post(self, request, pk):
old_platform = device.platform
device.platform = platform
- device.save()
+ try:
+ device.full_clean()
+ device.save()
+ except (ValidationError, IntegrityError) as e:
+ device.platform = old_platform
+ error_msg = e.message_dict if hasattr(e, "message_dict") else str(e)
+ messages.error(request, f"Failed to update platform to '{platform}': {error_msg}")
+ return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk)
if old_platform:
messages.success(
@@ -141,10 +191,22 @@ def post(self, request, pk):
return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk)
-class CreateAndAssignPlatformView(LibreNMSAPIMixin, View):
+class CreateAndAssignPlatformView(LibreNMSPermissionMixin, NetBoxObjectPermissionMixin, LibreNMSAPIMixin, View):
"""Create a new Platform and assign it to the device."""
+ required_object_permissions = {
+ "POST": [
+ ("change", Device),
+ ("add", Platform),
+ ],
+ }
+
def post(self, request, pk):
+ """Create a new platform and assign it to the device."""
+ # Check both plugin write and NetBox object permissions
+ if error := self.require_all_permissions("POST"):
+ return error
+
device = get_object_or_404(Device, pk=pk)
platform_name = request.POST.get("platform_name")
@@ -168,13 +230,28 @@ def post(self, request, pk):
except Manufacturer.DoesNotExist:
pass
- platform = Platform.objects.create(
- name=platform_name,
- manufacturer=manufacturer,
- )
+ try:
+ platform = Platform.objects.create(
+ name=platform_name,
+ manufacturer=manufacturer,
+ )
+ except IntegrityError:
+ messages.error(
+ request,
+ f"Platform '{platform_name}' could not be created (slug collision). Try a different name.",
+ )
+ return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk)
+ old_platform = device.platform
device.platform = platform
- device.save()
+ try:
+ device.full_clean()
+ device.save()
+ except (ValidationError, IntegrityError) as e:
+ device.platform = old_platform
+ error_msg = e.message_dict if hasattr(e, "message_dict") else str(e)
+ messages.error(request, f"Failed to assign platform '{platform}': {error_msg}")
+ return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk)
messages.success(
request,
@@ -184,10 +261,19 @@ def post(self, request, pk):
return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk)
-class AssignVCSerialView(LibreNMSAPIMixin, View):
+class AssignVCSerialView(LibreNMSPermissionMixin, NetBoxObjectPermissionMixin, LibreNMSAPIMixin, View):
"""Assign serial numbers to each virtual chassis member."""
+ required_object_permissions = {
+ "POST": [("change", Device)],
+ }
+
def post(self, request, pk):
+ """Sync serial numbers to virtual chassis member devices."""
+ # Check both plugin write and NetBox object permissions
+ if error := self.require_all_permissions("POST"):
+ return error
+
device = get_object_or_404(Device, pk=pk)
if not device.virtual_chassis:
@@ -214,8 +300,17 @@ def post(self, request, pk):
counter += 1
continue
+ old_serial = member.serial
member.serial = serial
- member.save()
+ try:
+ member.full_clean()
+ member.save()
+ except (ValidationError, IntegrityError) as e:
+ member.serial = old_serial
+ error_msg = e.message_dict if hasattr(e, "message_dict") else str(e)
+ errors.append(f"Failed to set serial on {member.name}: {error_msg}")
+ counter += 1
+ continue
assignments_made += 1
diff --git a/netbox_librenms_plugin/views/sync/devices.py b/netbox_librenms_plugin/views/sync/devices.py
index fcd199e5c2..da0f9af5b0 100644
--- a/netbox_librenms_plugin/views/sync/devices.py
+++ b/netbox_librenms_plugin/views/sync/devices.py
@@ -4,53 +4,62 @@
from django.views import View
from virtualization.models import VirtualMachine
-from netbox_librenms_plugin.forms import AddToLIbreSNMPV2, AddToLIbreSNMPV3
-from netbox_librenms_plugin.views.mixins import LibreNMSAPIMixin
+from netbox_librenms_plugin.forms import AddToLIbreSNMPV1V2, AddToLIbreSNMPV3
+from netbox_librenms_plugin.views.mixins import LibreNMSAPIMixin, LibreNMSPermissionMixin
-class AddDeviceToLibreNMSView(LibreNMSAPIMixin, View):
+class AddDeviceToLibreNMSView(LibreNMSPermissionMixin, LibreNMSAPIMixin, View):
"""Add a NetBox device or VM to LibreNMS via the API."""
def get_form_class(self):
+ """Return the appropriate SNMP form class based on the SNMP version."""
snmp_version = self.request.POST.get("snmp_version")
if not snmp_version:
- snmp_version = self.request.POST.get("v2-snmp_version") or self.request.POST.get("v3-snmp_version")
+ snmp_version = self.request.POST.get("v1v2-snmp_version") or self.request.POST.get("v3-snmp_version")
- if snmp_version == "v2c":
- return AddToLIbreSNMPV2
+ if snmp_version in ("v1", "v2c"):
+ return AddToLIbreSNMPV1V2
return AddToLIbreSNMPV3
def get_object(self, object_id):
+ """Return the Device or VirtualMachine for the given ID."""
try:
return Device.objects.get(pk=object_id)
except Device.DoesNotExist:
return VirtualMachine.objects.get(pk=object_id)
def post(self, request, object_id):
+ """Add a device to LibreNMS using the submitted SNMP form."""
+ # Check write permission before adding device to LibreNMS
+ if error := self.require_write_permission():
+ return error
+
self.object = self.get_object(object_id)
form_class = self.get_form_class()
- snmp_version = (
- request.POST.get("snmp_version")
- or request.POST.get("v2-snmp_version")
- or request.POST.get("v3-snmp_version")
- )
- prefix = "v2" if snmp_version == "v2c" else "v3"
+ snmp_version = request.POST.get("v1v2-snmp_version") or request.POST.get("v3-snmp_version")
+ prefix = "v1v2" if snmp_version in ("v1", "v2c") else "v3"
form = form_class(request.POST, prefix=prefix)
if form.is_valid():
- return self.form_valid(form)
+ # Inject snmp_version from toggle into cleaned_data for v1/v2c forms
+ if snmp_version in ("v1", "v2c"):
+ form.cleaned_data["snmp_version"] = snmp_version
+ return self.form_valid(form, snmp_version=snmp_version)
for field, errors in form.errors.items():
for error in errors:
messages.error(request, f"{field}: {error}")
return redirect(self.object.get_absolute_url())
- def form_valid(self, form):
+ def form_valid(self, form, snmp_version=None):
+ """Submit the validated SNMP form data to the LibreNMS API."""
data = form.cleaned_data
+ # Use the snmp_version from toggle/form for v1/v2c, or from form data for v3
+ version = snmp_version or data.get("snmp_version")
device_data = {
"hostname": data.get("hostname"),
- "snmp_version": data.get("snmp_version"),
+ "snmp_version": version,
"force_add": data.get("force_add", False),
}
@@ -66,7 +75,7 @@ def form_valid(self, form):
except (ValueError, TypeError):
pass
- if device_data["snmp_version"] == "v2c":
+ if device_data["snmp_version"] in ("v1", "v2c"):
device_data["community"] = data.get("community")
elif device_data["snmp_version"] == "v3":
device_data.update(
@@ -92,10 +101,15 @@ def form_valid(self, form):
return redirect(self.object.get_absolute_url())
-class UpdateDeviceLocationView(LibreNMSAPIMixin, View):
+class UpdateDeviceLocationView(LibreNMSPermissionMixin, LibreNMSAPIMixin, View):
"""Update the LibreNMS site/location based on the NetBox site."""
def post(self, request, pk):
+ """Sync the device location to LibreNMS from the NetBox site."""
+ # Check write permission before updating location in LibreNMS
+ if error := self.require_write_permission():
+ return error
+
device = get_object_or_404(Device, pk=pk)
self.librenms_id = self.librenms_api.get_librenms_id(device)
diff --git a/netbox_librenms_plugin/views/sync/interfaces.py b/netbox_librenms_plugin/views/sync/interfaces.py
index e9bbd0c22b..a19629cccf 100644
--- a/netbox_librenms_plugin/views/sync/interfaces.py
+++ b/netbox_librenms_plugin/views/sync/interfaces.py
@@ -10,21 +10,47 @@
from netbox_librenms_plugin.models import InterfaceTypeMapping
from netbox_librenms_plugin.utils import convert_speed_to_kbps, get_interface_name_field
-from netbox_librenms_plugin.views.mixins import CacheMixin
+from netbox_librenms_plugin.views.mixins import (
+ CacheMixin,
+ LibreNMSPermissionMixin,
+ NetBoxObjectPermissionMixin,
+ VlanAssignmentMixin,
+)
-class SyncInterfacesView(CacheMixin, View):
+class SyncInterfacesView(LibreNMSPermissionMixin, NetBoxObjectPermissionMixin, VlanAssignmentMixin, CacheMixin, View):
"""Sync selected interfaces from LibreNMS into NetBox."""
+ def get_required_permissions_for_object_type(self, object_type):
+ """Return the required permissions based on object type."""
+ if object_type == "device":
+ return [("add", Interface), ("change", Interface)]
+ elif object_type == "virtualmachine":
+ return [("add", VMInterface), ("change", VMInterface)]
+ else:
+ raise Http404(f"Invalid object type: {object_type}")
+
def post(self, request, object_type, object_id):
+ """Sync selected interfaces from LibreNMS into NetBox."""
+ # Set permissions dynamically based on object type
+ self.required_object_permissions = {
+ "POST": self.get_required_permissions_for_object_type(object_type),
+ }
+
+ # Check both plugin write and NetBox object permissions
+ if error := self.require_all_permissions("POST"):
+ return error
+
url_name = (
"dcim:device_librenms_sync"
if object_type == "device"
else "plugins:netbox_librenms_plugin:vm_librenms_sync"
)
obj = self.get_object(object_type, object_id)
+ self.object = obj # Store for use in sync methods
interface_name_field = get_interface_name_field(request)
+ self.interface_name_field = interface_name_field
selected_interfaces = self.get_selected_interfaces(request, interface_name_field)
exclude_columns = request.POST.getlist("exclude_columns")
@@ -41,6 +67,11 @@ def post(self, request, object_type, object_id):
+ f"?tab=interfaces&interface_name_field={interface_name_field}"
)
+ # Prepare VLAN lookup maps if VLAN sync is enabled
+ vlan_groups = self.get_vlan_groups_for_device(obj)
+ lookup_maps = self._build_vlan_lookup_maps(vlan_groups)
+ self._lookup_maps = lookup_maps
+
self.sync_selected_interfaces(obj, selected_interfaces, ports_data, exclude_columns, interface_name_field)
messages.success(request, "Selected interfaces synced successfully.")
@@ -49,6 +80,7 @@ def post(self, request, object_type, object_id):
)
def get_object(self, object_type, object_id):
+ """Return the Device or VirtualMachine for the given type and ID."""
if object_type == "device":
return get_object_or_404(Device, pk=object_id)
if object_type == "virtualmachine":
@@ -56,6 +88,7 @@ def get_object(self, object_type, object_id):
raise Http404("Invalid object type.")
def get_selected_interfaces(self, request, interface_name_field):
+ """Return the list of selected interface names from POST data."""
selected_interfaces = request.POST.getlist("select")
if not selected_interfaces:
messages.error(request, "No interfaces selected for synchronization.")
@@ -63,6 +96,7 @@ def get_selected_interfaces(self, request, interface_name_field):
return selected_interfaces
def get_cached_ports_data(self, request, obj):
+ """Return cached LibreNMS port data for the given object."""
cached_data = cache.get(self.get_cache_key(obj, "ports"))
if not cached_data:
messages.warning(
@@ -80,6 +114,7 @@ def sync_selected_interfaces(
exclude_columns,
interface_name_field,
):
+ """Create or update NetBox interfaces from LibreNMS port data."""
with transaction.atomic():
for port in ports_data:
port_name = port.get(interface_name_field)
@@ -88,6 +123,7 @@ def sync_selected_interfaces(
self.sync_interface(obj, port, exclude_columns, interface_name_field)
def sync_interface(self, obj, librenms_interface, exclude_columns, interface_name_field):
+ """Create or update a single NetBox interface from LibreNMS data."""
interface_name = librenms_interface.get(interface_name_field)
if isinstance(obj, Device):
@@ -95,7 +131,17 @@ def sync_interface(self, obj, librenms_interface, exclude_columns, interface_nam
selected_device_id = self.request.POST.get(device_selection_key)
if selected_device_id:
- target_device = Device.objects.get(id=selected_device_id)
+ try:
+ target_device = Device.objects.get(id=selected_device_id)
+ # Validate the target is the current device or a VC member
+ if hasattr(obj, "virtual_chassis") and obj.virtual_chassis:
+ valid_ids = set(obj.virtual_chassis.members.values_list("id", flat=True))
+ if target_device.id not in valid_ids:
+ target_device = obj
+ elif target_device.id != obj.id:
+ target_device = obj
+ except (Device.DoesNotExist, ValueError, TypeError):
+ target_device = obj
else:
target_device = obj
@@ -120,18 +166,28 @@ def sync_interface(self, obj, librenms_interface, exclude_columns, interface_nam
if "enabled" not in exclude_columns:
interface.enabled = (
True
- if librenms_interface["ifAdminStatus"] is None
+ if librenms_interface.get("ifAdminStatus") is None
else (
librenms_interface["ifAdminStatus"].lower() == "up"
if isinstance(librenms_interface["ifAdminStatus"], str)
else bool(librenms_interface["ifAdminStatus"])
)
)
- interface.save()
+
+ # Sync VLANs if not excluded
+ vlan_synced = False
+ if "vlans" not in exclude_columns:
+ self._sync_interface_vlans(interface, librenms_interface, interface_name)
+ vlan_synced = True
+
+ # Skip redundant save when _sync_interface_vlans already saved (via _update_interface_vlan_assignment)
+ if not vlan_synced:
+ interface.save()
def get_netbox_interface_type(self, librenms_interface):
- speed = convert_speed_to_kbps(librenms_interface["ifSpeed"])
- mappings = InterfaceTypeMapping.objects.filter(librenms_type=librenms_interface["ifType"])
+ """Return the NetBox interface type mapped from LibreNMS type and speed."""
+ speed = convert_speed_to_kbps(librenms_interface.get("ifSpeed"))
+ mappings = InterfaceTypeMapping.objects.filter(librenms_type=librenms_interface.get("ifType"))
if speed is not None:
speed_mapping = mappings.filter(librenms_speed__lte=speed).order_by("-librenms_speed").first()
@@ -142,6 +198,7 @@ def get_netbox_interface_type(self, librenms_interface):
return mapping.netbox_type if mapping else "other"
def handle_mac_address(self, interface, ifPhysAddress):
+ """Assign or create the MAC address for the given interface."""
if ifPhysAddress:
existing_mac = interface.mac_addresses.filter(mac_address=ifPhysAddress).first()
if existing_mac:
@@ -160,6 +217,7 @@ def update_interface_attributes(
exclude_columns,
interface_name_field,
):
+ """Update interface fields from LibreNMS data, respecting excluded columns."""
is_device_interface = isinstance(interface, Interface)
LIBRENMS_TO_NETBOX_MAPPING = {
@@ -195,11 +253,65 @@ def update_interface_attributes(
interface.save()
+ def _sync_interface_vlans(self, interface, librenms_port, interface_name):
+ """
+ Sync VLAN assignments from LibreNMS to NetBox interface.
+ Sets mode, untagged_vlan, and tagged_vlans based on LibreNMS data.
+
+ Args:
+ interface: NetBox Interface or VMInterface object
+ librenms_port: Port data dict from LibreNMS with VLAN info
+ interface_name: Original interface name for form field lookup
+ """
+ # Get per-VLAN group selections from form (safely handle special chars in name)
+ safe_name = interface_name.replace("/", "_").replace(":", "_")
+
+ # Build VLAN data from port
+ vlan_data = {
+ "untagged_vlan": librenms_port.get("untagged_vlan"),
+ "tagged_vlans": librenms_port.get("tagged_vlans", []),
+ }
-class DeleteNetBoxInterfacesView(CacheMixin, View):
+ # Build per-VLAN group map from POST data
+ vlan_group_map = {}
+ all_vids = []
+ if vlan_data["untagged_vlan"]:
+ all_vids.append(str(vlan_data["untagged_vlan"]))
+ for vid in vlan_data.get("tagged_vlans", []):
+ all_vids.append(str(vid))
+
+ for vid in all_vids:
+ group_id = self.request.POST.get(f"vlan_group_{safe_name}_{vid}", "")
+ if group_id:
+ vlan_group_map[vid] = group_id
+
+ # Use mixin method to update interface VLAN assignments
+ self._update_interface_vlan_assignment(interface, vlan_data, vlan_group_map, self._lookup_maps)
+
+
+class DeleteNetBoxInterfacesView(LibreNMSPermissionMixin, NetBoxObjectPermissionMixin, CacheMixin, View):
"""Delete interfaces that exist only in NetBox."""
+ def get_required_permissions_for_object_type(self, object_type):
+ """Return the required permissions based on object type."""
+ if object_type == "device":
+ return [("delete", Interface)]
+ elif object_type == "virtualmachine":
+ return [("delete", VMInterface)]
+ else:
+ raise Http404(f"Invalid object type: {object_type}")
+
def post(self, request, object_type, object_id):
+ """Delete selected NetBox-only interfaces not present in LibreNMS."""
+ # Set permissions dynamically based on object type
+ self.required_object_permissions = {
+ "POST": self.get_required_permissions_for_object_type(object_type),
+ }
+
+ # Check both plugin write and NetBox object permissions
+ if error := self.require_all_permissions_json("POST"):
+ return error
+
if object_type == "device":
obj = get_object_or_404(Device, pk=object_id)
elif object_type == "virtualmachine":
@@ -214,13 +326,16 @@ def post(self, request, object_type, object_id):
deleted_count = 0
errors = []
+ interface_name = None
try:
with transaction.atomic():
for interface_id in interface_ids:
+ interface_name = None
try:
if object_type == "device":
interface = Interface.objects.get(id=interface_id)
+ interface_name = interface.name
if hasattr(obj, "virtual_chassis") and obj.virtual_chassis:
valid_device_ids = [member.id for member in obj.virtual_chassis.members.all()]
if interface.device_id not in valid_device_ids:
@@ -235,11 +350,11 @@ def post(self, request, object_type, object_id):
continue
else:
interface = VMInterface.objects.get(id=interface_id)
+ interface_name = interface.name
if interface.virtual_machine_id != obj.id:
errors.append(f"Interface {interface.name} does not belong to this virtual machine")
continue
- interface_name = interface.name
interface.delete()
deleted_count += 1
@@ -247,7 +362,7 @@ def post(self, request, object_type, object_id):
errors.append(f"Interface with ID {interface_id} not found")
continue
except Exception as exc: # pragma: no cover - defensive
- errors.append(f"Error deleting interface {interface_name}: {str(exc)}")
+ errors.append(f"Error deleting interface {interface_name or interface_id}: {str(exc)}")
continue
except Exception as exc: # pragma: no cover
diff --git a/netbox_librenms_plugin/views/sync/ip_addresses.py b/netbox_librenms_plugin/views/sync/ip_addresses.py
index f51e58c6aa..474a3a446c 100644
--- a/netbox_librenms_plugin/views/sync/ip_addresses.py
+++ b/netbox_librenms_plugin/views/sync/ip_addresses.py
@@ -9,16 +9,25 @@
from ipam.models import VRF, IPAddress
from virtualization.models import VirtualMachine, VMInterface
-from netbox_librenms_plugin.views.mixins import CacheMixin
+from netbox_librenms_plugin.views.mixins import CacheMixin, LibreNMSPermissionMixin, NetBoxObjectPermissionMixin
-class SyncIPAddressesView(CacheMixin, View):
+class SyncIPAddressesView(LibreNMSPermissionMixin, NetBoxObjectPermissionMixin, CacheMixin, View):
"""Synchronize IP addresses from LibreNMS cache into NetBox."""
+ required_object_permissions = {
+ "POST": [
+ ("add", IPAddress),
+ ("change", IPAddress),
+ ],
+ }
+
def get_selected_ips(self, request):
+ """Return selected IP addresses from POST data."""
return [x for x in request.POST.getlist("select") if x]
def get_vrf_selection(self, request, ip_address):
+ """Return the VRF selected for a given IP address, or None."""
vrf_id = request.POST.get(f"vrf_{ip_address}")
if vrf_id:
@@ -30,12 +39,14 @@ def get_vrf_selection(self, request, ip_address):
return None
def get_cached_ip_data(self, request, obj):
+ """Return cached LibreNMS IP address data for the given object."""
cached_data = cache.get(self.get_cache_key(obj, "ip_addresses"))
if not cached_data:
return None
return cached_data.get("ip_addresses", [])
def get_object(self, object_type, pk):
+ """Return the Device or VirtualMachine instance for the given type and pk."""
if object_type == "device":
return get_object_or_404(Device, pk=pk)
if object_type == "virtualmachine":
@@ -43,6 +54,7 @@ def get_object(self, object_type, pk):
raise Http404("Invalid object type.")
def get_ip_tab_url(self, obj):
+ """Return the URL for the IP addresses sync tab."""
if isinstance(obj, Device):
url_name = "plugins:netbox_librenms_plugin:device_librenms_sync"
else:
@@ -50,6 +62,11 @@ def get_ip_tab_url(self, obj):
return f"{reverse(url_name, args=[obj.pk])}?tab=ipaddresses"
def post(self, request, object_type, pk):
+ """Sync selected IP addresses from LibreNMS into NetBox."""
+ # Check both plugin write and NetBox object permissions
+ if error := self.require_all_permissions("POST"):
+ return error
+
obj = self.get_object(object_type, pk)
selected_ips = self.get_selected_ips(request)
@@ -69,6 +86,7 @@ def post(self, request, object_type, pk):
return redirect(self.get_ip_tab_url(obj))
def process_ip_sync(self, request, selected_ips, cached_ips, obj, object_type):
+ """Create or update IP addresses in NetBox from cached LibreNMS data."""
results = {"created": [], "updated": [], "unchanged": [], "failed": []}
with transaction.atomic():
@@ -113,6 +131,7 @@ def process_ip_sync(self, request, selected_ips, cached_ips, obj, object_type):
return results
def display_sync_results(self, request, results):
+ """Display flash messages summarizing the IP sync results."""
if results["created"]:
messages.success(request, f"Created IP addresses: {', '.join(results['created'])}")
if results["updated"]:
diff --git a/netbox_librenms_plugin/views/sync/locations.py b/netbox_librenms_plugin/views/sync/locations.py
index c1e7e06895..915ed61941 100644
--- a/netbox_librenms_plugin/views/sync/locations.py
+++ b/netbox_librenms_plugin/views/sync/locations.py
@@ -8,10 +8,10 @@
from netbox_librenms_plugin.filtersets import SiteLocationFilterSet
from netbox_librenms_plugin.tables.locations import SiteLocationSyncTable
-from netbox_librenms_plugin.views.mixins import LibreNMSAPIMixin
+from netbox_librenms_plugin.views.mixins import LibreNMSAPIMixin, LibreNMSPermissionMixin
-class SyncSiteLocationView(LibreNMSAPIMixin, SingleTableView):
+class SyncSiteLocationView(LibreNMSPermissionMixin, LibreNMSAPIMixin, SingleTableView):
"""Synchronize NetBox Sites with LibreNMS locations."""
table_class = SiteLocationSyncTable
@@ -22,17 +22,20 @@ class SyncSiteLocationView(LibreNMSAPIMixin, SingleTableView):
SyncData = namedtuple("SyncData", ["netbox_site", "librenms_location", "is_synced"])
def get_table(self, *args, **kwargs):
+ """Return the configured sync table."""
table = super().get_table(*args, **kwargs)
table.configure(self.request)
return table
def get_context_data(self, **kwargs):
+ """Return context with filter form for site-location sync."""
context = super().get_context_data(**kwargs)
queryset = self.get_queryset()
context["filter_form"] = self.filterset(self.request.GET, queryset=queryset).form
return context
def get_queryset(self):
+ """Return sync data pairing NetBox sites with LibreNMS locations."""
netbox_sites = Site.objects.all()
success, librenms_locations = self.get_librenms_locations()
if not success or not isinstance(librenms_locations, list):
@@ -50,9 +53,11 @@ def get_queryset(self):
return sync_data
def get_librenms_locations(self):
+ """Fetch all locations from LibreNMS."""
return self.librenms_api.get_locations()
def create_sync_data(self, site, librenms_locations):
+ """Create a SyncData tuple pairing a site with its LibreNMS location."""
matched_location = self.match_site_with_location(site, librenms_locations)
if matched_location:
is_synced = self.check_coordinates_match(
@@ -65,12 +70,14 @@ def create_sync_data(self, site, librenms_locations):
return self.SyncData(site, None, False)
def match_site_with_location(self, site, librenms_locations):
+ """Return the LibreNMS location matching the given site, or None."""
for location in librenms_locations:
if location["location"].lower() == site.name.lower() or location["location"].lower() == site.slug.lower():
return location
return None
def check_coordinates_match(self, site_lat, site_lng, librenms_lat, librenms_lng):
+ """Return True if site and LibreNMS coordinates match within tolerance."""
if None in (site_lat, site_lng, librenms_lat, librenms_lng):
return False
lat_match = abs(float(site_lat) - float(librenms_lat)) < self.COORDINATE_TOLERANCE
@@ -78,6 +85,11 @@ def check_coordinates_match(self, site_lat, site_lng, librenms_lat, librenms_lng
return lat_match and lng_match
def post(self, request):
+ """Handle create or update of a LibreNMS location from a NetBox site."""
+ # Check write permission before modifying LibreNMS locations
+ if error := self.require_write_permission():
+ return error
+
action = request.POST.get("action")
pk = request.POST.get("pk")
if not pk:
@@ -98,12 +110,14 @@ def post(self, request):
return redirect("plugins:netbox_librenms_plugin:site_location_sync")
def get_site_by_pk(self, pk):
+ """Return the Site for the given pk, or None if not found."""
try:
return Site.objects.get(pk=pk)
except ObjectDoesNotExist:
return None
def create_librenms_location(self, request, site):
+ """Create a new location in LibreNMS from the given site."""
location_data = self.build_location_data(site)
success, message = self.librenms_api.add_location(location_data)
if success:
@@ -116,6 +130,7 @@ def create_librenms_location(self, request, site):
return redirect("plugins:netbox_librenms_plugin:site_location_sync")
def update_librenms_location(self, request, site):
+ """Update an existing LibreNMS location with the site coordinates."""
if site.latitude is None or site.longitude is None:
messages.warning(
request,
@@ -145,6 +160,7 @@ def update_librenms_location(self, request, site):
return redirect("plugins:netbox_librenms_plugin:site_location_sync")
def build_location_data(self, site, include_name=True):
+ """Build a location data dict from the given site."""
data = {"lat": str(site.latitude), "lng": str(site.longitude)}
if include_name:
data["location"] = site.name
diff --git a/netbox_librenms_plugin/views/sync/vlans.py b/netbox_librenms_plugin/views/sync/vlans.py
new file mode 100644
index 0000000000..c5fae91530
--- /dev/null
+++ b/netbox_librenms_plugin/views/sync/vlans.py
@@ -0,0 +1,161 @@
+from dcim.models import Device
+from django.contrib import messages
+from django.core.cache import cache
+from django.db import transaction
+from django.http import Http404
+from django.shortcuts import get_object_or_404, redirect
+from django.urls import reverse
+from django.views import View
+from ipam.models import VLAN, VLANGroup
+
+from netbox_librenms_plugin.views.mixins import CacheMixin, LibreNMSPermissionMixin, NetBoxObjectPermissionMixin
+
+
+class SyncVLANsView(LibreNMSPermissionMixin, NetBoxObjectPermissionMixin, CacheMixin, View):
+ """
+ Handle POST requests to create/update VLANs in NetBox from LibreNMS data.
+ """
+
+ required_object_permissions = {
+ "POST": [
+ ("add", VLAN),
+ ("change", VLAN),
+ ],
+ }
+
+ def post(self, request, object_type: str, object_id: int):
+ """
+ Process sync request.
+
+ Expected POST data:
+ - action: 'create_vlans'
+ - select: List of VLAN IDs to create
+ - vlan_group_{vid}: Per-row VLAN group selection
+ """
+ # Check both plugin write and NetBox object permissions
+ if error := self.require_all_permissions("POST"):
+ return error
+
+ obj = self.get_object(object_type, object_id)
+ action = request.POST.get("action", "")
+
+ if action == "create_vlans":
+ return self._handle_create_vlans(request, obj, object_type, object_id)
+ else:
+ messages.error(request, "Invalid action specified.")
+ return self._redirect(object_type, object_id)
+
+ def get_object(self, object_type: str, object_id: int):
+ """Get the target object (Device or VM)."""
+ if object_type == "device":
+ return get_object_or_404(Device, pk=object_id)
+ raise Http404("Invalid object type.")
+
+ def _redirect(self, object_type: str, object_id: int):
+ """Redirect back to sync page with VLAN tab active."""
+ url_name = (
+ "dcim:device_librenms_sync"
+ if object_type == "device"
+ else "plugins:netbox_librenms_plugin:vm_librenms_sync"
+ )
+ return redirect(reverse(url_name, kwargs={"pk": object_id}) + "?tab=vlans")
+
+ def _handle_create_vlans(self, request, obj, object_type, object_id):
+ """
+ Handle creating selected VLANs in NetBox.
+
+ Reads per-row VLAN group selections from form fields named 'vlan_group_{vid}'.
+ """
+ selected_vlans = request.POST.getlist("select")
+
+ if not selected_vlans:
+ messages.error(request, "No VLANs selected for creation.")
+ return self._redirect(object_type, object_id)
+
+ # Get cached VLAN data
+ cached_vlans = cache.get(self.get_cache_key(obj, "vlans"))
+ if not cached_vlans:
+ messages.error(request, "No cached VLAN data. Please refresh VLANs first.")
+ return self._redirect(object_type, object_id)
+
+ # Build lookup of LibreNMS VLANs by VID
+ librenms_vlans = {str(v["vlan_vlan"]): v for v in cached_vlans}
+
+ created_count = 0
+ updated_count = 0
+ skipped_count = 0
+
+ with transaction.atomic():
+ for vid_str in selected_vlans:
+ try:
+ vid = int(vid_str)
+ except ValueError:
+ continue
+
+ vlan_data = librenms_vlans.get(vid_str)
+ if not vlan_data:
+ continue
+
+ # Get per-row VLAN group selection
+ group_id_str = request.POST.get(f"vlan_group_{vid}", "")
+ row_vlan_group = None
+ if group_id_str:
+ try:
+ row_vlan_group = VLANGroup.objects.get(pk=int(group_id_str))
+ except (ValueError, VLANGroup.DoesNotExist):
+ pass # Fall back to global VLAN (no group)
+
+ librenms_name = vlan_data.get("vlan_name", f"VLAN {vid}")
+
+ if row_vlan_group:
+ # Grouped VLAN: match by VID (unique constraint within group)
+ vlan, created = VLAN.objects.get_or_create(
+ vid=vid,
+ group=row_vlan_group,
+ defaults={
+ "name": librenms_name,
+ "status": "active",
+ },
+ )
+ if created:
+ created_count += 1
+ elif vlan.name != librenms_name:
+ vlan.name = librenms_name
+ vlan.save()
+ updated_count += 1
+ else:
+ skipped_count += 1
+ else:
+ # Global VLAN: match by VID only (unique constraint with group=NULL)
+ vlan, created = VLAN.objects.get_or_create(
+ vid=vid,
+ group=None,
+ defaults={
+ "name": librenms_name,
+ "status": "active",
+ },
+ )
+ if created:
+ created_count += 1
+ elif vlan.name != librenms_name:
+ vlan.name = librenms_name
+ vlan.save()
+ updated_count += 1
+ else:
+ skipped_count += 1
+
+ # Build summary message
+ parts = []
+ if created_count > 0:
+ parts.append(f"{created_count} created")
+ if updated_count > 0:
+ parts.append(f"{updated_count} updated")
+ if skipped_count > 0:
+ parts.append(f"{skipped_count} unchanged")
+
+ if parts:
+ messages.success(request, f"VLANs synced: {', '.join(parts)}.")
+ else:
+ messages.warning(request, "No VLANs were created or updated.")
+
+ return self._redirect(object_type, object_id)
diff --git a/pyproject.toml b/pyproject.toml
index 596ae21b5a..83c70fc848 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -32,7 +32,7 @@ include-package-data = true
[tool.setuptools.packages.find]
include = ["netbox_librenms_plugin*"]
-exclude = ["site*"]
+exclude = ["site*", "netbox_librenms_plugin.tests*"]
[tool.setuptools.package-data]
netbox_librenms_plugin = ["templates/**"]