diff --git a/netbox_librenms_plugin/import_utils/device_operations.py b/netbox_librenms_plugin/import_utils/device_operations.py index 6fb760529..6b87935b0 100644 --- a/netbox_librenms_plugin/import_utils/device_operations.py +++ b/netbox_librenms_plugin/import_utils/device_operations.py @@ -749,7 +749,7 @@ def import_single_device( 'interfaces': int, 'cables': int, 'ip_addresses': int - } + }, } """ try: diff --git a/netbox_librenms_plugin/import_utils/vm_operations.py b/netbox_librenms_plugin/import_utils/vm_operations.py index a630b5563..99ae10a8a 100644 --- a/netbox_librenms_plugin/import_utils/vm_operations.py +++ b/netbox_librenms_plugin/import_utils/vm_operations.py @@ -151,6 +151,10 @@ def bulk_import_vms( # Use job logger if available, otherwise standard logger log = job.logger if job else logger + # Resolve options once before the loop — they do not change per-VM + use_sysname_opt = sync_options.get("use_sysname", True) if sync_options else True + strip_domain_opt = sync_options.get("strip_domain", False) if sync_options else False + for idx, vm_id in enumerate(vm_ids, start=1): # Check for job cancellation before first VM and every 5 thereafter if job and (idx == 1 or idx % 5 == 0) and _is_job_cancelled(job): @@ -173,8 +177,6 @@ def bulk_import_vms( continue # Validate as VM - use_sysname_opt = sync_options.get("use_sysname", True) if sync_options else True - strip_domain_opt = sync_options.get("strip_domain", False) if sync_options else False validation = validate_device_for_import( libre_device, import_as_vm=True, diff --git a/netbox_librenms_plugin/static/netbox_librenms_plugin/js/librenms_import.js b/netbox_librenms_plugin/static/netbox_librenms_plugin/js/librenms_import.js index a2facc706..edfda206a 100644 --- a/netbox_librenms_plugin/static/netbox_librenms_plugin/js/librenms_import.js +++ b/netbox_librenms_plugin/static/netbox_librenms_plugin/js/librenms_import.js @@ -349,8 +349,8 @@ * Persists toggle state to user preferences on change. */ function initializeTogglePrefs() { - const sysname = document.getElementById('use-sysname-toggle'); - const strip = document.getElementById('strip-domain-toggle'); + const sysname = document.getElementById('use-sysname-toggle-cb'); + const strip = document.getElementById('strip-domain-toggle-cb'); if (sysname) sysname.addEventListener('change', function () { savePref('use_sysname', this.checked); }); if (strip) strip.addEventListener('change', function () { savePref('strip_domain', this.checked); }); } @@ -1199,10 +1199,14 @@ const dismissTrigger = event.target.closest('[data-bs-dismiss="modal"]'); if (dismissTrigger) { - event.preventDefault(); - - // Check if it's in the HTMX modal - if (modalElement.contains(dismissTrigger)) { + // Only handle dismiss triggers whose nearest .modal ancestor IS + // the outer HTMX modal. Buttons inside nested modals (e.g. the + // Promote-to-host modal rendered inside #htmx-modal-content) + // must be left for Bootstrap's own dismiss handler so they + // close the inner modal, not the outer one. + const nearestModal = dismissTrigger.closest('.modal'); + if (nearestModal === modalElement) { + event.preventDefault(); hideModal(modalElement, fallbackBackdropRef); } } diff --git a/netbox_librenms_plugin/tables/ipaddresses.py b/netbox_librenms_plugin/tables/ipaddresses.py index 8c9e1bfe2..0324c6de2 100644 --- a/netbox_librenms_plugin/tables/ipaddresses.py +++ b/netbox_librenms_plugin/tables/ipaddresses.py @@ -33,6 +33,7 @@ class Meta: row_attrs = { "data-interface": lambda record: record["ip_address"], "data-name": lambda record: record["ip_address"], + "data-mgmt-ip": lambda record: "true" if record.get("is_mgmt_ip") else "", } selection = ToggleColumn( diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/_ipaddress_sync_content.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/_ipaddress_sync_content.html index e73bb8396..535ecaa55 100644 --- a/netbox_librenms_plugin/templates/netbox_librenms_plugin/_ipaddress_sync_content.html +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/_ipaddress_sync_content.html @@ -10,11 +10,21 @@ {% if ip_sync.server_key %}{% endif %}
diff --git a/netbox_librenms_plugin/tests/test_coverage_base_views.py b/netbox_librenms_plugin/tests/test_coverage_base_views.py index f2456d7be..5a691570a 100644 --- a/netbox_librenms_plugin/tests/test_coverage_base_views.py +++ b/netbox_librenms_plugin/tests/test_coverage_base_views.py @@ -2259,3 +2259,57 @@ def test_netbox_only_interfaces_non_vc_device_name_from_obj(self): gi01 = next((i for i in netbox_only if i["name"] == "Gi0/1"), None) assert gi01 is not None assert gi01["device_name"] == "router-1" + + +# ============================================================================= +# BaseIPAddressTableView._flag_management_ip +# ============================================================================= + + +class TestBaseIPAddressTableViewFlagManagementIp: + """Tests for marking the LibreNMS management-IP row (Set Primary IP support).""" + + def _make_view(self, librenms_id=42): + from netbox_librenms_plugin.views.base.ip_addresses_view import BaseIPAddressTableView + + view = object.__new__(BaseIPAddressTableView) + view.librenms_id = librenms_id + view._librenms_api = MagicMock() + view._librenms_api.server_key = "default" + return view + + def test_flags_matching_entry(self): + view = self._make_view() + view._librenms_api.get_device_info.return_value = (True, {"ip": "10.0.0.1"}) + data = [{"ip_address": "10.0.0.5"}, {"ip_address": "10.0.0.1"}] + view._flag_management_ip(data) + assert data[0].get("is_mgmt_ip") is None + assert data[1].get("is_mgmt_ip") is True + + def test_no_flag_when_no_match(self): + view = self._make_view() + view._librenms_api.get_device_info.return_value = (True, {"ip": "192.0.2.9"}) + data = [{"ip_address": "10.0.0.1"}] + view._flag_management_ip(data) + assert data[0].get("is_mgmt_ip") is None + + def test_no_flag_when_no_librenms_id(self): + view = self._make_view(librenms_id=None) + data = [{"ip_address": "10.0.0.1"}] + view._flag_management_ip(data) + view._librenms_api.get_device_info.assert_not_called() + assert data[0].get("is_mgmt_ip") is None + + def test_no_flag_when_device_info_fails(self): + view = self._make_view() + view._librenms_api.get_device_info.return_value = (False, None) + data = [{"ip_address": "10.0.0.1"}] + view._flag_management_ip(data) + assert data[0].get("is_mgmt_ip") is None + + def test_no_flag_when_mgmt_ip_blank(self): + view = self._make_view() + view._librenms_api.get_device_info.return_value = (True, {"ip": ""}) + data = [{"ip_address": "10.0.0.1"}] + view._flag_management_ip(data) + assert data[0].get("is_mgmt_ip") is None diff --git a/netbox_librenms_plugin/tests/test_coverage_sync_views.py b/netbox_librenms_plugin/tests/test_coverage_sync_views.py index 552b67e25..131b8abc0 100644 --- a/netbox_librenms_plugin/tests/test_coverage_sync_views.py +++ b/netbox_librenms_plugin/tests/test_coverage_sync_views.py @@ -2583,3 +2583,92 @@ def test_no_vlans_created_shows_warning(self): with patch.object(view, "_redirect", return_value=MagicMock()): view._handle_create_vlans(req, mock_obj, "device", 1) mock_msg.warning.assert_called_once() + + +class TestSyncIPAddressesViewSetPrimaryIp: + """Phase 1: auto-match the LibreNMS management IP and set it as Primary IP.""" + + def _setup_view(self): + from netbox_librenms_plugin.views.sync.ip_addresses import SyncIPAddressesView + + view = _make_view(SyncIPAddressesView) + view._post_server_key = "default" + return view + + def test_same_host(self): + from netbox_librenms_plugin.views.sync.ip_addresses import SyncIPAddressesView as V + + assert V._same_host("10.0.0.1", "10.0.0.1") is True + assert V._same_host("10.0.0.1", "10.0.0.2") is False + assert V._same_host("not-an-ip", "10.0.0.1") is False + # IPv6 equality across differing textual forms + assert V._same_host("2001:db8::1", "2001:0db8:0000:0000:0000:0000:0000:0001") is True + + def test_set_primary_ip_sets_ipv4_and_is_idempotent(self): + from netbox_librenms_plugin.views.sync.ip_addresses import SyncIPAddressesView as V + + ip_obj = MagicMock(family=4, pk=42) + obj = MagicMock() + obj.primary_ip4_id = None + assert V._set_primary_ip(obj, ip_obj) is True + assert obj.primary_ip4 is ip_obj + obj.save.assert_called_once() + + # Already pointing at this IP -> no change, no extra save + obj.save.reset_mock() + obj.primary_ip4_id = 42 + assert V._set_primary_ip(obj, ip_obj) is False + obj.save.assert_not_called() + + def test_set_primary_ip_uses_v6_field(self): + from netbox_librenms_plugin.views.sync.ip_addresses import SyncIPAddressesView as V + + ip_obj = MagicMock(family=6, pk=7) + obj = MagicMock() + obj.primary_ip6_id = None + assert V._set_primary_ip(obj, ip_obj) is True + assert obj.primary_ip6 is ip_obj + + def _run_process(self, view, cached, *, mgmt_ip, set_primary=True, interface=True): + selected = ["10.0.0.1"] + created_ip = MagicMock(family=4, pk=42) + obj = MagicMock() + obj.primary_ip4_id = None + with patch("netbox_librenms_plugin.views.sync.ip_addresses.resolve_set_primary_ip", return_value=set_primary): + with patch.object(view, "get_management_ip", return_value=mgmt_ip) as mock_mgmt: + with patch("netbox_librenms_plugin.views.sync.ip_addresses.transaction", _atomic_txn()): + with patch("netbox_librenms_plugin.views.sync.ip_addresses.IPAddress") as mock_ip_cls: + mock_ip_cls.objects.filter.return_value.first.return_value = None + mock_ip_cls.objects.create.return_value = created_ip + with patch("netbox_librenms_plugin.views.sync.ip_addresses.Interface") as mock_iface_cls: + mock_iface_cls.objects.get.return_value = MagicMock() + with patch.object(view, "get_vrf_selection", return_value=None): + results = view.process_ip_sync(view.request, selected, cached, obj, "device") + return results, obj, created_ip, mock_mgmt + + def test_primary_set_when_matched_and_interface_assigned(self): + view = self._setup_view() + cached = [{"ip_address": "10.0.0.1", "ip_with_mask": "10.0.0.1/24", "interface_url": "/api/dcim/interfaces/5/"}] + results, obj, created_ip, _ = self._run_process(view, cached, mgmt_ip="10.0.0.1") + assert results["primary_set"] == ["10.0.0.1"] + assert obj.primary_ip4 is created_ip + + def test_primary_skipped_when_no_interface(self): + view = self._setup_view() + cached = [{"ip_address": "10.0.0.1", "ip_with_mask": "10.0.0.1/24", "interface_url": None}] + results, obj, _, _ = self._run_process(view, cached, mgmt_ip="10.0.0.1") + assert results["primary_set"] == [] + obj.save.assert_not_called() + + def test_primary_skipped_when_ip_does_not_match_mgmt(self): + view = self._setup_view() + cached = [{"ip_address": "10.0.0.1", "ip_with_mask": "10.0.0.1/24", "interface_url": "/api/dcim/interfaces/5/"}] + results, obj, _, _ = self._run_process(view, cached, mgmt_ip="10.9.9.9") + assert results["primary_set"] == [] + + def test_toggle_off_skips_mgmt_lookup_and_primary(self): + view = self._setup_view() + cached = [{"ip_address": "10.0.0.1", "ip_with_mask": "10.0.0.1/24", "interface_url": "/api/dcim/interfaces/5/"}] + results, obj, _, mock_mgmt = self._run_process(view, cached, mgmt_ip="10.0.0.1", set_primary=False) + assert results["primary_set"] == [] + mock_mgmt.assert_not_called() diff --git a/netbox_librenms_plugin/tests/test_import_utils.py b/netbox_librenms_plugin/tests/test_import_utils.py index 27551436f..f7d8620c0 100644 --- a/netbox_librenms_plugin/tests/test_import_utils.py +++ b/netbox_librenms_plugin/tests/test_import_utils.py @@ -5895,3 +5895,70 @@ def _capture_create(**kwargs): create_virtual_chassis_with_members(master_device, members_info, {"device_id": 1}) assert mock_device_cls.objects.create.call_count == 1 + + +class TestResolveSetPrimaryIp: + """Phase 1: resolve_set_primary_ip cascade (POST/GET toggle -> user pref -> False).""" + + def _make_request(self, post=None, get=None): + from unittest.mock import MagicMock + + request = MagicMock() + request.POST = post or {} + request.GET = get or {} + request.user = MagicMock() + return request + + def test_defaults_false_when_nothing_set(self): + from unittest.mock import patch + + from netbox_librenms_plugin.utils import resolve_set_primary_ip + + request = self._make_request() + with patch("netbox_librenms_plugin.utils.get_user_pref", return_value=None): + assert resolve_set_primary_ip(request) is False + + def test_post_toggle_on(self): + from unittest.mock import patch + + from netbox_librenms_plugin.utils import resolve_set_primary_ip + + request = self._make_request(post={"set-primary-ip-toggle": "on"}) + with patch("netbox_librenms_plugin.utils.get_user_pref", return_value=None): + assert resolve_set_primary_ip(request) is True + + def test_post_toggle_off_overrides_pref(self): + from unittest.mock import patch + + from netbox_librenms_plugin.utils import resolve_set_primary_ip + + request = self._make_request(post={"set-primary-ip-toggle": "off"}) + with patch("netbox_librenms_plugin.utils.get_user_pref", return_value=True): + assert resolve_set_primary_ip(request) is False + + def test_underscore_key_variant(self): + from unittest.mock import patch + + from netbox_librenms_plugin.utils import resolve_set_primary_ip + + request = self._make_request(post={"set_primary_ip": "1"}) + with patch("netbox_librenms_plugin.utils.get_user_pref", return_value=None): + assert resolve_set_primary_ip(request) is True + + def test_get_used_when_not_in_post(self): + from unittest.mock import patch + + from netbox_librenms_plugin.utils import resolve_set_primary_ip + + request = self._make_request(get={"set-primary-ip-toggle": "true"}) + with patch("netbox_librenms_plugin.utils.get_user_pref", return_value=None): + assert resolve_set_primary_ip(request) is True + + def test_user_pref_bool_used_when_no_toggle(self): + from unittest.mock import patch + + from netbox_librenms_plugin.utils import resolve_set_primary_ip + + request = self._make_request() + with patch("netbox_librenms_plugin.utils.get_user_pref", return_value=True): + assert resolve_set_primary_ip(request) is True diff --git a/netbox_librenms_plugin/tests/test_template_comments.py b/netbox_librenms_plugin/tests/test_template_comments.py new file mode 100644 index 000000000..6f49df4f7 --- /dev/null +++ b/netbox_librenms_plugin/tests/test_template_comments.py @@ -0,0 +1,36 @@ +"""Guard against multi-line Django ``{# #}`` comments. + +Django's ``{# #}`` comment syntax must stay on a single line. A multi-line +``{# ... #}`` is NOT recognised as a comment and renders as literal text in the +page (e.g. the platform-cell pencil-icon partial once leaked its header comment +into the import modal). ``get_template()`` only *parses* templates, so this slips +past template-compile checks — hence this static scan. Use ``{% comment %}`` for +multi-line comments instead. +""" + +import pathlib + +import netbox_librenms_plugin + +TEMPLATES_DIR = pathlib.Path(netbox_librenms_plugin.__file__).parent / "templates" + + +def _html_templates(): + return sorted(TEMPLATES_DIR.rglob("*.html")) + + +def test_templates_found(): + """Sanity check that the scan actually has templates to look at.""" + assert _html_templates(), f"No .html templates found under {TEMPLATES_DIR}" + + +def test_no_multiline_single_hash_comments(): + """No line may contain an unbalanced ``{#`` / ``#}`` (a multi-line ``{# #}``).""" + offenders = [] + for path in _html_templates(): + for lineno, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1): + if line.count("{#") != line.count("#}"): + offenders.append(f"{path.relative_to(TEMPLATES_DIR)}:{lineno}: {line.strip()}") + assert not offenders, "Multi-line `{# #}` comments render as literal text — use `{% comment %}`:\n" + "\n".join( + offenders + ) diff --git a/netbox_librenms_plugin/urls.py b/netbox_librenms_plugin/urls.py index 4d7881c17..73b954c54 100644 --- a/netbox_librenms_plugin/urls.py +++ b/netbox_librenms_plugin/urls.py @@ -13,6 +13,7 @@ from .views import ( AddDeviceToLibreNMSView, AddDeviceTypeMappingView, + AddPlatformMappingView, AssignVCSerialView, BulkImportConfirmView, BulkImportDevicesView, @@ -429,6 +430,11 @@ AddDeviceTypeMappingView.as_view(), name="add_device_type_mapping", ), + path( + "device-import/add-platform-mapping/