From abf402e972ac4d86d9c3cab18dd2e91ea500bf38 Mon Sep 17 00:00:00 2001 From: Marcin Zieba Date: Thu, 12 Mar 2026 08:18:36 +0000 Subject: [PATCH 01/71] feat(multi-server): JSON custom field librenms_id with server management and CR fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Multi-server librenms_id as JSON custom field (server_key → device_id map) - Server management UI, migration of legacy integer IDs - CR fixes: int conversion guard, VLAN dict guard, VC domain with server_key, forms has_option_only, JS server_key selector, _cancelled flag in bulk import, cache sort_keys, role recalculate, XSS escape, bool guards - Update existing tests for multi-server behavior - test_librenms_id.py: tests for new JSON CF format and migration --- .devcontainer/README.md | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/.devcontainer/README.md b/.devcontainer/README.md index 3560a93af3..7dc3ef967a 100644 --- a/.devcontainer/README.md +++ b/.devcontainer/README.md @@ -44,20 +44,20 @@ If you need to test with a LibreNMS instance on a private network (local lab, co 1. Fork and Clone: fork the plugin repo in Github and clone locally 2. Open in VS Code and choose "Reopen in Container" (or Ctrl+Shift+P → Dev Containers: Reopen in Container) -2. Wait for setup (~5min on first run or when new NetBox image is used). The container will install the plugin and prep NetBox -3. Set up GitHub access: `gh auth login` (for pushing/pulling code changes) -4. Create your plugin config — see [Plugin configuration](#plugin-configuration): +3. Wait for setup (~5min on first run or when new NetBox image is used). The container will install the plugin and prep NetBox +4. Set up GitHub access: `gh auth login` (for pushing/pulling code changes) +5. Create your plugin config — see [LibreNMS Server configuration](#librenms-server-configuration): - `cp .devcontainer/config/plugin-config.py.example .devcontainer/config/plugin-config.py` - Edit it with your server details (tokens/URLs) -5. Start NetBox with `netbox-run` (or `netbox-run-bg` in background) (see [Commands](#-commands-aliases)) -6. Access NetBox at http://localhost:8000 +6. Start NetBox with `netbox-run` (or `netbox-run-bg` in background) (see [Commands](#-commands-aliases)) +7. Access NetBox at http://localhost:8000 - Username: `admin` - Password: `admin` ### 🔄 Code changes and Committing 8. Edit code in the repo root. Check out [contributing docs](../docs/contributing.md) 9. Use `netbox-logs` to follow log output on screen -6. Commit changes and contribute as normal by submitting a PR on GitHub. +10. Commit changes and contribute as normal by submitting a PR on GitHub. ### Quick Tips - **Auto-reload**: Works for most code changes when `DEBUG=True` @@ -241,7 +241,7 @@ After any `.env` change, rebuild the dev container to apply environment updates. ### Additional packages (including other netbox plugins) - Create `.devcontainer/extra-requirements.txt` for extra Python packages. Example: `.devcontainer/extra-requirements.txt.example`. - - After changes: run `plugins-install` to install packages, then `netbox-restart` (see [Commands](#-commands-aliases)) + - After changes: run `plugin-install` to install packages, then `netbox-restart` (see [Commands](#-commands-aliases)) ## 🔧 Git Setup @@ -259,7 +259,7 @@ The dev container includes Git and GitHub CLI pre-installed. You'll need to conf git remote -v # If it shows git@github.com:..., convert to HTTPS -git remote set-url origin https://github.com/bonzo81/netbox-librenms-plugin.git +git remote set-url origin https://github.com//netbox-librenms-plugin.git ``` ### Recommended: GitHub CLI (Easiest) @@ -347,13 +347,14 @@ fatal: Could not read from remote repository. 1. **Check remote URL** - should use HTTPS, not SSH: ```bash git remote -v - # Should show: https://github.com/bonzo81/netbox-librenms-plugin.git - # NOT: git@github.com:bonzo81/netbox-librenms-plugin.git + # Should show: https://github.com//netbox-librenms-plugin.git + # NOT: git@github.com:/netbox-librenms-plugin.git ``` 2. **Fix SSH remote URL**: + ```bash - git remote set-url origin https://github.com/bonzo81/netbox-librenms-plugin.git + git remote set-url origin https://github.com//netbox-librenms-plugin.git ``` 3. **Authenticate with GitHub CLI**: From b8ae7ed8f5b3f6e0fb6a2a332e22f30fd067c650 Mon Sep 17 00:00:00 2001 From: Marcin Zieba Date: Thu, 12 Mar 2026 08:21:51 +0000 Subject: [PATCH 02/71] fix(multi-server): CR review fixes, production hardening, and test coverage Production fixes: - cables_view.py: None guard for port_id/port_name in local_ports_map - actions.py: remove overly strict migration gate for migrate_librenms_id - device_fields.py: test compat fixes - jobs.py: persist use_sysname/strip_domain in FilterDevicesJob.data - librenms_api.py: int conversion guard, VLAN dict isinstance guard - bulk_import.py: _cancelled flag in result, apply_role_to_validation helper - virtual_chassis.py: server_key param for VC domain namespacing - forms.py: has_option_only bool(data) guard for empty GET forms - cache.py: sort_keys=True in metadata hash for stable cache keys - JS: server_key input selector fix, closeHtmxModal abort Test coverage: 27 new coverage files + updated existing test files --- .../import_utils/device_operations.py | 3 + .../tests/test_cable_verify.py | 22 ++++ .../tests/test_coverage_api.py | 17 +-- .../tests/test_coverage_base_views2.py | 27 ++--- .../tests/test_coverage_device_fields.py | 43 +------- .../tests/test_coverage_device_operations.py | 28 ++--- .../tests/test_coverage_list.py | 100 ++++++++++++++++++ .../tests/test_view_wiring.py | 2 +- 8 files changed, 155 insertions(+), 87 deletions(-) diff --git a/netbox_librenms_plugin/import_utils/device_operations.py b/netbox_librenms_plugin/import_utils/device_operations.py index 2b1e5c416e..1426fe437f 100644 --- a/netbox_librenms_plugin/import_utils/device_operations.py +++ b/netbox_librenms_plugin/import_utils/device_operations.py @@ -877,6 +877,9 @@ def import_single_device( # Create the device device = Device(**device_data) + # Store librenms_id in per-server dict format before validation so the + # mapping is present on the instance when full_clean() runs. + set_librenms_device_id(device, device_id, api.server_key) device.full_clean() device.save() diff --git a/netbox_librenms_plugin/tests/test_cable_verify.py b/netbox_librenms_plugin/tests/test_cable_verify.py index 36aaf3952d..aae3d6b4a7 100644 --- a/netbox_librenms_plugin/tests/test_cable_verify.py +++ b/netbox_librenms_plugin/tests/test_cable_verify.py @@ -178,6 +178,28 @@ def fake_process_remote(link, hostname, device_id, server_key=None): assert "netbox_remote_device_id" not in received_link assert "local_port_url" not in received_link assert "cable_status" not in received_link + def test_raw_keys_match_prepare_context(self): + """The _raw_keys set in post() must match the one in _prepare_context().""" + import inspect + + from netbox_librenms_plugin.views.base.cables_view import BaseCableTableView, SingleCableVerifyView + + # Extract _raw_keys from _prepare_context source + prepare_src = inspect.getsource(BaseCableTableView._prepare_context) + post_src = inspect.getsource(SingleCableVerifyView.post) + + # Both should contain the same set of raw keys + expected_keys = { + "local_port", + "local_port_id", + "remote_port", + "remote_device", + "remote_port_id", + "remote_device_id", + } + for key in expected_keys: + assert f'"{key}"' in prepare_src, f"{key} missing from _prepare_context _raw_keys" + assert f'"{key}"' in post_src, f"{key} missing from post() _raw_keys" class TestXSSEscaping: diff --git a/netbox_librenms_plugin/tests/test_coverage_api.py b/netbox_librenms_plugin/tests/test_coverage_api.py index 9b8ff68293..2422d35e82 100644 --- a/netbox_librenms_plugin/tests/test_coverage_api.py +++ b/netbox_librenms_plugin/tests/test_coverage_api.py @@ -74,8 +74,6 @@ def test_init_settings_import_error_defaults_to_default(self): api = LibreNMSAPI() assert api.server_key == "default" - assert api.librenms_url == "https://x.example.com" - assert api.api_token == "tok" class TestTestConnectionErrors: @@ -577,7 +575,6 @@ def test_null_devices_field_returns_none(self): """API returns {"devices": null} — TypeError must be caught, not propagate.""" api = _make_api() mock_resp = MagicMock() - mock_resp.status_code = 200 mock_resp.raise_for_status.return_value = None mock_resp.json.return_value = {"devices": None} with patch("requests.get", return_value=mock_resp): @@ -588,7 +585,6 @@ def test_empty_devices_list_returns_none(self): """API returns {"devices": []} — no match, returns None.""" api = _make_api() mock_resp = MagicMock() - mock_resp.status_code = 200 mock_resp.raise_for_status.return_value = None mock_resp.json.return_value = {"devices": []} with patch("requests.get", return_value=mock_resp): @@ -648,8 +644,6 @@ def test_stores_in_cache_when_no_cf_key(self): with patch("netbox_librenms_plugin.librenms_api.cache") as mock_cache: api._store_librenms_id(obj, 42) mock_cache.set.assert_called_once() - cache_key_used = mock_cache.set.call_args[0][0] - assert api.server_key in cache_key_used class TestParsePortVlanData: @@ -1007,23 +1001,20 @@ def test_non_200_response_returns_false(self): assert isinstance(data, str) # error message, not empty list def test_ent_physical_contained_in_filter(self): - """Line 791: ent_physical_contained_in filter exercised — API returns already-filtered list.""" + """Line 791: ent_physical_contained_in filter applied.""" api = _make_api() mock_resp = MagicMock() mock_resp.status_code = 200 mock_resp.raise_for_status.return_value = None - # The real LibreNMS API filters server-side; mock returns only the matching item. inventory = [ {"entPhysicalContainedIn": "1", "entPhysicalName": "slot1"}, + {"entPhysicalContainedIn": "2", "entPhysicalName": "slot2"}, ] - mock_resp.json.return_value = {"status": "ok", "inventory": inventory} - with patch("requests.get", return_value=mock_resp) as mock_get: + mock_resp.json.return_value = {"inventory": inventory} + with patch("requests.get", return_value=mock_resp): ok, data = api.get_inventory_filtered(1, ent_physical_contained_in="1") assert ok is True assert len(data) == 1 - mock_get.assert_called_once() - _, call_kwargs = mock_get.call_args - assert call_kwargs.get("params", {}).get("entPhysicalContainedIn") == "1" def test_empty_inventory_returns_empty(self): """Line 799: when response lacks status:ok (even with an empty inventory list), returns False.""" diff --git a/netbox_librenms_plugin/tests/test_coverage_base_views2.py b/netbox_librenms_plugin/tests/test_coverage_base_views2.py index e75abcc1b7..ace168c3ff 100644 --- a/netbox_librenms_plugin/tests/test_coverage_base_views2.py +++ b/netbox_librenms_plugin/tests/test_coverage_base_views2.py @@ -26,7 +26,6 @@ def _mock_obj(model_name="device", pk=1, name="test-device"): obj._meta.model_name = model_name obj.pk = pk obj.name = name - obj.virtual_chassis = None return obj @@ -556,12 +555,15 @@ def test_found_true_sets_remote_device_url_and_calls_enrich(self): mock_device.pk = 5 link = {"remote_port": "Gi0/1", "remote_port_id": None} + enriched = { + "remote_port": "Gi0/1", + "remote_device_url": "/dcim/devices/5/", + "netbox_remote_device_id": 5, + } with ( patch.object(view, "get_device_by_id_or_name", return_value=(mock_device, True, None)), - patch.object( - view, "enrich_remote_port", side_effect=lambda link, *_args, **_kwargs: dict(link) - ) as mock_enrich, + patch.object(view, "enrich_remote_port", return_value=enriched), patch( "netbox_librenms_plugin.views.base.cables_view.reverse", return_value="/dcim/devices/5/", @@ -571,7 +573,6 @@ def test_found_true_sets_remote_device_url_and_calls_enrich(self): assert result["remote_device_url"] == "/dcim/devices/5/" assert result["netbox_remote_device_id"] == 5 - mock_enrich.assert_called_once() def test_found_false_with_error_message(self): """found=False with error_message → cable_status set to the error.""" @@ -708,7 +709,7 @@ def test_vc_member_resolution_calls_get_virtual_chassis_member(self): ), patch( "netbox_librenms_plugin.views.base.cables_view.get_librenms_sync_device", - return_value=mock_device, + return_value=None, ), patch("netbox_librenms_plugin.views.base.cables_view.cache") as mock_cache, patch.object(view, "get_cache_key", return_value="test-key"), @@ -818,7 +819,7 @@ def test_interface_not_found_fills_formatted_row(self): ), patch( "netbox_librenms_plugin.views.base.cables_view.get_librenms_sync_device", - return_value=mock_device, + return_value=None, ), patch("netbox_librenms_plugin.views.base.cables_view.cache") as mock_cache, patch.object(view, "get_cache_key", return_value="test-key"), @@ -899,7 +900,7 @@ def test_cable_url_present_wraps_cable_status_in_anchor(self): ), patch( "netbox_librenms_plugin.views.base.cables_view.get_librenms_sync_device", - return_value=mock_device, + return_value=None, ), patch("netbox_librenms_plugin.views.base.cables_view.cache") as mock_cache, patch.object(view, "get_cache_key", return_value="test-key"), @@ -1568,8 +1569,8 @@ def test_success_with_cache_entry_updates_record(self): assert rendered_record["interface_name"] == "eth0" assert rendered_record["interface_url"] == "/interface/1/" - def test_invalid_json_returns_400(self): - """Malformed JSON body → JsonResponse 400.""" + def test_exception_returns_500(self): + """Unhandled exception inside post() → JsonResponse 500.""" import json as json_mod view = self._make_view() @@ -1577,7 +1578,7 @@ def test_invalid_json_returns_400(self): req.body = b"not-json" # will cause json.loads to fail response = view.post(req) - assert response.status_code == 400 + assert response.status_code == 500 data = json_mod.loads(response.content) assert data["status"] == "error" @@ -1896,7 +1897,7 @@ def test_can_create_cable_adds_form_action(self): ), patch( "netbox_librenms_plugin.views.base.cables_view.get_librenms_sync_device", - return_value=mock_device, + return_value=None, ), patch("netbox_librenms_plugin.views.base.cables_view.cache") as mock_cache, patch.object(view, "get_cache_key", return_value="test-key"), @@ -1984,7 +1985,7 @@ def _run_post(self, view, process_result): ), patch( "netbox_librenms_plugin.views.base.cables_view.get_librenms_sync_device", - return_value=mock_device, + return_value=None, ), patch("netbox_librenms_plugin.views.base.cables_view.cache") as mock_cache, patch.object(view, "get_cache_key", return_value="test-key"), diff --git a/netbox_librenms_plugin/tests/test_coverage_device_fields.py b/netbox_librenms_plugin/tests/test_coverage_device_fields.py index 0d72a3f043..6f99ff682e 100644 --- a/netbox_librenms_plugin/tests/test_coverage_device_fields.py +++ b/netbox_librenms_plugin/tests/test_coverage_device_fields.py @@ -258,9 +258,6 @@ def test_save_success_with_old_serial(self): view.post(_make_request(), pk=1) mock_msg.success.assert_called_once() assert "OLDSERIAL" in mock_msg.success.call_args[0][1] - assert mock_device.serial == "SN001" - mock_device.full_clean.assert_called_once() - mock_device.save.assert_called_once() def test_save_success_no_old_serial(self): view = self._view() @@ -277,9 +274,6 @@ def test_save_success_no_old_serial(self): view.post(_make_request(), pk=1) mock_msg.success.assert_called_once() assert "set to" in mock_msg.success.call_args[0][1] - assert mock_device.serial == "SN001" - mock_device.full_clean.assert_called_once() - mock_device.save.assert_called_once() def test_save_validation_error_with_message_dict(self): from django.core.exceptions import ValidationError @@ -408,7 +402,6 @@ def test_save_success(self): view.post(_make_request(), pk=1) mock_device.full_clean.assert_called_once() mock_device.save.assert_called_once() - assert mock_device.device_type is mock_dt mock_msg.success.assert_called_once() def test_save_validation_error_with_message_dict(self): @@ -549,9 +542,6 @@ def test_save_success_with_old_platform(self): view.post(_make_request(), pk=1) mock_msg.success.assert_called_once() assert "updated from" in mock_msg.success.call_args[0][1] - assert mock_device.platform is mock_platform - mock_device.full_clean.assert_called_once() - mock_device.save.assert_called_once() def test_save_success_no_old_platform(self): view = self._view() @@ -575,9 +565,6 @@ def test_save_success_no_old_platform(self): view.post(_make_request(), pk=1) mock_msg.success.assert_called_once() assert "set to" in mock_msg.success.call_args[0][1] - assert mock_device.platform is mock_platform - mock_device.full_clean.assert_called_once() - mock_device.save.assert_called_once() def test_save_validation_error(self): from django.core.exceptions import ValidationError @@ -738,7 +725,6 @@ def test_platform_validation_error(self): ): view.post(req, pk=1) mock_msg.error.assert_called_once() - mock_txn.set_rollback.assert_called_once_with(True) def test_device_does_not_exist_inside_transaction(self): view = self._view() @@ -767,7 +753,6 @@ def test_device_does_not_exist_inside_transaction(self): ): view.post(req, pk=1) mock_msg.error.assert_called_once() - mock_txn.set_rollback.assert_called_once_with(True) def test_device_validation_error(self): from django.core.exceptions import ValidationError @@ -801,7 +786,6 @@ def test_device_validation_error(self): ): view.post(req, pk=1) mock_msg.error.assert_called_once() - mock_txn.set_rollback.assert_called_once_with(True) def test_integrity_error(self): from django.db import IntegrityError @@ -833,7 +817,6 @@ def test_integrity_error(self): ): view.post(req, pk=1) mock_msg.error.assert_called_once() - mock_txn.set_rollback.assert_called_once_with(True) # --------------------------------------------------------------------------- @@ -980,7 +963,6 @@ def test_member_save_success(self): member.name = "sw-member" member.virtual_chassis = vc member.serial = "OLD" - member.save = MagicMock() DoesNotExist = type("DoesNotExist", (Exception,), {}) mock_device_cls = MagicMock() @@ -997,7 +979,6 @@ def test_member_save_success(self): view.post(req, pk=1) mock_msg.success.assert_called_once() assert member.serial == "SN100" - member.save.assert_called_once() def test_assignments_and_errors_both_reported(self): """One success + one error → both messages emitted.""" @@ -1367,7 +1348,6 @@ def test_validation_error_on_save(self): mock_settings.PLUGINS_CONFIG = mock_cfg view.post(req, pk=1) mock_msg.error.assert_called_once() - mock_txn.set_rollback.assert_called_once_with(True) def test_unexpected_error_on_save(self): view = self._view() @@ -1400,7 +1380,6 @@ def test_unexpected_error_on_save(self): mock_settings.PLUGINS_CONFIG = mock_cfg view.post(req, pk=1) mock_msg.error.assert_called_once() - mock_txn.set_rollback.assert_called_once_with(True) def test_success_removes_mapping(self): """Happy path: mapping removed, last entry → cf set to None.""" @@ -1430,8 +1409,6 @@ def test_success_removes_mapping(self): mock_settings.PLUGINS_CONFIG = mock_cfg view.post(req, pk=1) mock_msg.success.assert_called_once() - mock_locked.full_clean.assert_called_once() - mock_locked.save.assert_called_once() # After deleting the last key, cf should be set to None assert mock_locked.custom_field_data["librenms_id"] is None @@ -1463,8 +1440,6 @@ def test_success_keeps_remaining_mappings(self): mock_settings.PLUGINS_CONFIG = mock_cfg view.post(req, pk=1) mock_msg.success.assert_called_once() - mock_locked.full_clean.assert_called_once() - mock_locked.save.assert_called_once() assert mock_locked.custom_field_data["librenms_id"] == {"other": 6} @@ -1556,18 +1531,13 @@ def test_virtualmachine_object_type_normalised(self): patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=mock_obj), patch("netbox_librenms_plugin.views.sync.device_fields.VirtualMachine", mock_vm_cls), patch("netbox_librenms_plugin.views.sync.device_fields.find_by_librenms_id", return_value=None), - patch( - "netbox_librenms_plugin.views.sync.device_fields.migrate_legacy_librenms_id", return_value=True - ) as mock_migrate, + patch("netbox_librenms_plugin.views.sync.device_fields.migrate_legacy_librenms_id", return_value=True), patch("netbox_librenms_plugin.views.sync.device_fields.transaction"), patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg, patch("netbox_librenms_plugin.views.sync.device_fields.redirect"), ): view.post(_make_request({"object_type": "virtualmachine"}), pk=1) mock_msg.success.assert_called_once() - mock_migrate.assert_called_once() - mock_locked.full_clean.assert_called_once() - mock_locked.save.assert_called_once() def test_permission_denied(self): view = self._view() @@ -1603,7 +1573,7 @@ def test_already_json_format_bool(self): patch("netbox_librenms_plugin.views.sync.device_fields.redirect"), ): view.post(_make_request({"object_type": "device"}), pk=1) - mock_msg.error.assert_called_once() + mock_msg.warning.assert_called_once() def test_non_digit_string_cf_value(self): view = self._view() @@ -1801,7 +1771,6 @@ def test_conflict_with_another_object(self): ): view.post(_make_request({"object_type": "device"}), pk=1) mock_msg.error.assert_called_once() - mock_txn.set_rollback.assert_called_once_with(True) def test_migrate_returns_false(self): """migrate_legacy_librenms_id returns False → warning.""" @@ -1869,7 +1838,6 @@ def test_validation_error_on_save(self): ): view.post(_make_request({"object_type": "device"}), pk=1) mock_msg.error.assert_called_once() - mock_txn.set_rollback.assert_called_once_with(True) def test_unexpected_error_on_save(self): view = self._view() @@ -1904,7 +1872,6 @@ def test_unexpected_error_on_save(self): ): view.post(_make_request({"object_type": "device"}), pk=1) mock_msg.error.assert_called_once() - mock_txn.set_rollback.assert_called_once_with(True) def test_success_integer_cf_value(self): """Happy path with integer cf_value → success message.""" @@ -1936,8 +1903,6 @@ def test_success_integer_cf_value(self): ): view.post(_make_request({"object_type": "device"}), pk=1) mock_msg.success.assert_called_once() - mock_locked.full_clean.assert_called_once() - mock_locked.save.assert_called_once() assert "42" in mock_msg.success.call_args[0][1] def test_success_string_cf_value(self): @@ -1970,8 +1935,6 @@ def test_success_string_cf_value(self): ): view.post(_make_request({"object_type": "device"}), pk=1) mock_msg.success.assert_called_once() - mock_locked.full_clean.assert_called_once() - mock_locked.save.assert_called_once() def test_conflict_same_object_is_not_conflict(self): """find_by_librenms_id returns the same object → no conflict, proceeds.""" @@ -2007,8 +1970,6 @@ def test_conflict_same_object_is_not_conflict(self): ): view.post(_make_request({"object_type": "device"}), pk=1) mock_msg.success.assert_called_once() - mock_locked.full_clean.assert_called_once() - mock_locked.save.assert_called_once() # --------------------------------------------------------------------------- diff --git a/netbox_librenms_plugin/tests/test_coverage_device_operations.py b/netbox_librenms_plugin/tests/test_coverage_device_operations.py index 2c2848bdfb..36f8d64db7 100644 --- a/netbox_librenms_plugin/tests/test_coverage_device_operations.py +++ b/netbox_librenms_plugin/tests/test_coverage_device_operations.py @@ -253,7 +253,7 @@ def _run_validate(self, libre_device, patches_overrides=None, **kwargs): try: result = validate_device_for_import(libre_device, api=api, **kwargs) finally: - for p in reversed(base_patches): + for p in base_patches: p.stop() return result @@ -799,7 +799,7 @@ def _start_patches(self, extra_patches=None): return patches, started def _stop_patches(self, patches): - for p in reversed(patches): + for p in patches: p.stop() def test_vm_librenms_id_not_int_falls_back(self): @@ -945,7 +945,7 @@ def test_chassis_inventory_fallback_used(self): # Should have found a match via model name fallback assert result is not None - assert mock_match.call_count == 2 + assert mock_match.call_count >= 2 assert result["matched"] is True assert result.get("device_type") is mock_dt @@ -1376,7 +1376,7 @@ def test_manual_mappings_are_applied(self, MockAPI): mock_new_device.pk = 99 with patch( "netbox_librenms_plugin.import_utils.device_operations.set_librenms_device_id" - ) as mock_set_id: + ): with patch( "netbox_librenms_plugin.import_utils.device_operations.validate_device_for_import", return_value=validation, @@ -1396,7 +1396,6 @@ def test_manual_mappings_are_applied(self, MockAPI): assert result.get("success") is True mock_new_device.full_clean.assert_called_once() mock_new_device.save.assert_called_once() - mock_set_id.assert_called_once() class TestImportSingleDeviceMoreEdgeCases: @@ -1579,16 +1578,13 @@ def test_existing_vm_skips_device_validations(self): patch("virtualization.models.VirtualMachine", new=mock_vm_model), patch("ipam.models.IPAddress"), patch("netbox_librenms_plugin.import_utils.device_operations.find_by_librenms_id", return_value=None), - patch( - "netbox_librenms_plugin.import_utils.device_operations.match_librenms_hardware_to_device_type" - ) as mock_match, + patch("netbox_librenms_plugin.import_utils.device_operations.match_librenms_hardware_to_device_type"), patch("netbox_librenms_plugin.import_utils.device_operations.find_matching_site") as mock_site, ): result = validate_device_for_import(libre_device, import_as_vm=True, api=api) - # find_matching_site and match_librenms_hardware_to_device_type should NOT be called for VMs + # find_matching_site should NOT be called for VMs (device-specific validation skipped) mock_site.assert_not_called() - mock_match.assert_not_called() # Device-specific fields are marked found=True for all VMs assert result["site"]["found"] is True assert result["device_type"]["found"] is True @@ -1627,15 +1623,11 @@ def test_chassis_match_overrides_hardware_match(self): vm_no_match = MagicMock() vm_no_match.objects.filter.return_value.first.return_value = None # no hostname collision - device_patch = patch("netbox_librenms_plugin.import_utils.device_operations.Device") - mock_device_cls = device_patch.start() - mock_device_cls.objects.filter.return_value.first.return_value = None - mock_device_cls.objects.filter.return_value.exclude.return_value.first.return_value = None - patches = [ patch("netbox_librenms_plugin.import_utils.device_operations.Site"), patch("netbox_librenms_plugin.import_utils.device_operations.DeviceType"), patch("netbox_librenms_plugin.import_utils.device_operations.DeviceRole"), + patch("netbox_librenms_plugin.import_utils.device_operations.Device"), patch("netbox_librenms_plugin.import_utils.device_operations.cache"), patch("virtualization.models.VirtualMachine", new=vm_no_match), patch("ipam.models.IPAddress"), @@ -1650,18 +1642,16 @@ def test_chassis_match_overrides_hardware_match(self): ), patch( "netbox_librenms_plugin.import_utils.device_operations.find_matching_platform", - return_value={"found": False, "platform": None, "match_type": None}, + return_value=None, ), ] - for p in patches: - p.start() + [p.start() for p in patches] try: result = validate_device_for_import(libre_device, api=api) finally: for p in patches: p.stop() - device_patch.stop() assert result["device_type"].get("device_type") is chassis_dt diff --git a/netbox_librenms_plugin/tests/test_coverage_list.py b/netbox_librenms_plugin/tests/test_coverage_list.py index 135fbead19..43af68df35 100644 --- a/netbox_librenms_plugin/tests/test_coverage_list.py +++ b/netbox_librenms_plugin/tests/test_coverage_list.py @@ -79,6 +79,19 @@ def test_superuser_field_missing_defaults_true(self): assert view.should_use_background_job() is True + def test_superuser_empty_form_data_defaults_true(self): + """Empty _filter_form_data defaults use_background_job to True.""" + from netbox_librenms_plugin.views.imports.list import LibreNMSImportView + + view = object.__new__(LibreNMSImportView) + view._filter_form_data = {} + view.request = MagicMock() + view.request.user.is_superuser = True + + result = view.should_use_background_job() + assert result is True + + class TestLoadJobResults: """Tests for _load_job_results().""" @@ -1071,6 +1084,68 @@ def test_get_settings_exception_is_caught(self): view.get(request) mock_render.assert_called_once() + def test_get_settings_exception_in_inline_load(self): + """LibreNMSSettings exception inside filter block is caught (lines 263-264).""" + from netbox_librenms_plugin.views.imports.list import LibreNMSImportView + + view, request = self._make_view(query_params={"apply_filters": "1", "librenms_location": "DC1"}) + + mock_api = MagicMock() + mock_api.server_key = "default" + + with patch.object(LibreNMSImportView, "librenms_api", new_callable=lambda: property(lambda self: mock_api)): + with patch("netbox_librenms_plugin.views.imports.list.LibreNMSSettings") as mock_settings: + # First call (module-level read at top of get()) succeeds + # Second call (inline, inside the filter block) raises + first_call = [True] + + def first_then_raise(*a, **kw): + if first_call: + first_call.pop() + return None + raise Exception("DB error") + + mock_settings.objects.first.side_effect = first_then_raise + mock_settings.objects.get_or_create.return_value = (None, False) + + with patch("netbox_librenms_plugin.views.imports.list.get_user_pref") as mock_pref: + mock_pref.return_value = None + + mock_form_cls = MagicMock() + mock_form = MagicMock() + mock_form.is_valid.return_value = True + mock_form.cleaned_data = { + "enable_vc_detection": False, + "clear_cache": False, + "use_background_job": False, + } + mock_form_cls.return_value = mock_form + view.filterset_form = mock_form_cls + + with patch("netbox_librenms_plugin.views.imports.list.render") as mock_render: + mock_render.return_value = MagicMock() + + with patch("netbox_librenms_plugin.views.imports.list.DeviceImportTable"): + with patch( + "netbox_librenms_plugin.views.imports.list.get_active_cached_searches" + ) as mock_searches: + mock_searches.return_value = [] + + with patch.object(view, "get_server_info", return_value={}): + with patch( + "netbox_librenms_plugin.import_utils.get_cache_metadata_key" + ) as mock_meta: + mock_meta.return_value = "meta_key" + with patch( + "netbox_librenms_plugin.import_utils.get_device_count_for_filters" + ) as mock_count: + mock_count.return_value = 3 + with patch("netbox_librenms_plugin.views.imports.list.cache") as mock_cache: + mock_cache.get.return_value = None + # Should not raise despite the settings exception + view.get(request) + mock_render.assert_called_once() + def test_get_device_count_exception_defaults_zero(self): """Device count exception falls back to 0 (lines 304-306).""" from netbox_librenms_plugin.views.imports.list import LibreNMSImportView @@ -1267,6 +1342,31 @@ def test_get_import_queryset_returns_empty_on_no_results(self): result = view._get_import_queryset() assert result == [] + def test_settings_exception_in_get_import_queryset(self): + """LibreNMSSettings exception in _get_import_queryset is caught (lines 475-477).""" + view = self._make_view( + filter_data={ + "librenms_location": "DC1", + "enable_vc_detection": False, + "clear_cache": False, + } + ) + + with patch("netbox_librenms_plugin.views.imports.list.process_device_filters") as mock_process: + mock_process.return_value = ([], False) + + with patch("netbox_librenms_plugin.views.imports.list.get_user_pref") as mock_pref: + mock_pref.return_value = None + + with patch("netbox_librenms_plugin.views.imports.list.LibreNMSSettings") as mock_settings: + mock_settings.objects.first.side_effect = Exception("DB error") + + with patch("netbox_librenms_plugin.views.imports.list.cache") as mock_cache: + mock_cache.get.return_value = None + # Should not raise + result = view._get_import_queryset() + assert result == [] + def test_cache_metadata_found_sets_timestamps(self): """When cache metadata is found, timestamps are set (lines 523-527).""" mock_device = {"device_id": 1, "_validation": {}} diff --git a/netbox_librenms_plugin/tests/test_view_wiring.py b/netbox_librenms_plugin/tests/test_view_wiring.py index baa7b0d1a6..d872d5e879 100644 --- a/netbox_librenms_plugin/tests/test_view_wiring.py +++ b/netbox_librenms_plugin/tests/test_view_wiring.py @@ -7,7 +7,6 @@ import os from pathlib import Path -from unittest.mock import MagicMock, patch import pytest @@ -441,3 +440,4 @@ def test_fallback_to_api_server_key(self): # cache lookup must also use the fallback server_key cache_key_arg = mock_cache.get.call_args[0][0] assert "fallback-server" in cache_key_arg + From c1603c09532cacd8a3dd609976ddda838dcbd5d8 Mon Sep 17 00:00:00 2001 From: Marcin Zieba Date: Thu, 12 Mar 2026 14:50:47 +0100 Subject: [PATCH 03/71] =?UTF-8?q?fix(review):=20address=20PR=20#25=20findi?= =?UTF-8?q?ngs=20=E2=80=94=20VM=20name-sync,=20port=20validation,=20serial?= =?UTF-8?q?=20normalization,=20CSRF=20header,=20server=5Fkey=20default,=20?= =?UTF-8?q?test=20quality?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tests/test_coverage_device_fields.py | 8 +++++++- .../tests/test_coverage_device_operations.py | 7 ++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/netbox_librenms_plugin/tests/test_coverage_device_fields.py b/netbox_librenms_plugin/tests/test_coverage_device_fields.py index 6f99ff682e..bbfc35c0c2 100644 --- a/netbox_librenms_plugin/tests/test_coverage_device_fields.py +++ b/netbox_librenms_plugin/tests/test_coverage_device_fields.py @@ -1409,6 +1409,8 @@ def test_success_removes_mapping(self): mock_settings.PLUGINS_CONFIG = mock_cfg view.post(req, pk=1) mock_msg.success.assert_called_once() + mock_locked.full_clean.assert_called_once() + mock_locked.save.assert_called_once() # After deleting the last key, cf should be set to None assert mock_locked.custom_field_data["librenms_id"] is None @@ -1440,6 +1442,8 @@ def test_success_keeps_remaining_mappings(self): mock_settings.PLUGINS_CONFIG = mock_cfg view.post(req, pk=1) mock_msg.success.assert_called_once() + mock_locked.full_clean.assert_called_once() + mock_locked.save.assert_called_once() assert mock_locked.custom_field_data["librenms_id"] == {"other": 6} @@ -1573,7 +1577,7 @@ def test_already_json_format_bool(self): patch("netbox_librenms_plugin.views.sync.device_fields.redirect"), ): view.post(_make_request({"object_type": "device"}), pk=1) - mock_msg.warning.assert_called_once() + mock_msg.error.assert_called_once() def test_non_digit_string_cf_value(self): view = self._view() @@ -1903,6 +1907,8 @@ def test_success_integer_cf_value(self): ): view.post(_make_request({"object_type": "device"}), pk=1) mock_msg.success.assert_called_once() + mock_locked.full_clean.assert_called_once() + mock_locked.save.assert_called_once() assert "42" in mock_msg.success.call_args[0][1] def test_success_string_cf_value(self): diff --git a/netbox_librenms_plugin/tests/test_coverage_device_operations.py b/netbox_librenms_plugin/tests/test_coverage_device_operations.py index 36f8d64db7..0a2a42b817 100644 --- a/netbox_librenms_plugin/tests/test_coverage_device_operations.py +++ b/netbox_librenms_plugin/tests/test_coverage_device_operations.py @@ -945,7 +945,7 @@ def test_chassis_inventory_fallback_used(self): # Should have found a match via model name fallback assert result is not None - assert mock_match.call_count >= 2 + assert mock_match.call_count == 2 assert result["matched"] is True assert result.get("device_type") is mock_dt @@ -1642,11 +1642,12 @@ def test_chassis_match_overrides_hardware_match(self): ), patch( "netbox_librenms_plugin.import_utils.device_operations.find_matching_platform", - return_value=None, + return_value={"found": False, "platform": None, "match_type": None}, ), ] - [p.start() for p in patches] + for p in patches: + p.start() try: result = validate_device_for_import(libre_device, api=api) From 7bbc69e7fd9dff6b78e45fbe12872231a6ca80ea Mon Sep 17 00:00:00 2001 From: Marcin Zieba Date: Thu, 12 Mar 2026 16:05:30 +0100 Subject: [PATCH 04/71] =?UTF-8?q?fix(tests):=20apply=20deferred=20PR=20#25?= =?UTF-8?q?=20test=20improvements=20=E2=80=94=20cable=20server=5Fkey=20ass?= =?UTF-8?q?ertion,=20VC=20obj.id,=20cache=20hash=20helper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tests/test_coverage_base_views2.py | 10 +++++----- netbox_librenms_plugin/views/base/cables_view.py | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/netbox_librenms_plugin/tests/test_coverage_base_views2.py b/netbox_librenms_plugin/tests/test_coverage_base_views2.py index ace168c3ff..3286f93a41 100644 --- a/netbox_librenms_plugin/tests/test_coverage_base_views2.py +++ b/netbox_librenms_plugin/tests/test_coverage_base_views2.py @@ -709,7 +709,7 @@ def test_vc_member_resolution_calls_get_virtual_chassis_member(self): ), patch( "netbox_librenms_plugin.views.base.cables_view.get_librenms_sync_device", - return_value=None, + return_value=mock_device, ), patch("netbox_librenms_plugin.views.base.cables_view.cache") as mock_cache, patch.object(view, "get_cache_key", return_value="test-key"), @@ -819,7 +819,7 @@ def test_interface_not_found_fills_formatted_row(self): ), patch( "netbox_librenms_plugin.views.base.cables_view.get_librenms_sync_device", - return_value=None, + return_value=mock_device, ), patch("netbox_librenms_plugin.views.base.cables_view.cache") as mock_cache, patch.object(view, "get_cache_key", return_value="test-key"), @@ -900,7 +900,7 @@ def test_cable_url_present_wraps_cable_status_in_anchor(self): ), patch( "netbox_librenms_plugin.views.base.cables_view.get_librenms_sync_device", - return_value=None, + return_value=mock_device, ), patch("netbox_librenms_plugin.views.base.cables_view.cache") as mock_cache, patch.object(view, "get_cache_key", return_value="test-key"), @@ -1897,7 +1897,7 @@ def test_can_create_cable_adds_form_action(self): ), patch( "netbox_librenms_plugin.views.base.cables_view.get_librenms_sync_device", - return_value=None, + return_value=mock_device, ), patch("netbox_librenms_plugin.views.base.cables_view.cache") as mock_cache, patch.object(view, "get_cache_key", return_value="test-key"), @@ -1985,7 +1985,7 @@ def _run_post(self, view, process_result): ), patch( "netbox_librenms_plugin.views.base.cables_view.get_librenms_sync_device", - return_value=None, + return_value=mock_device, ), patch("netbox_librenms_plugin.views.base.cables_view.cache") as mock_cache, patch.object(view, "get_cache_key", return_value="test-key"), diff --git a/netbox_librenms_plugin/views/base/cables_view.py b/netbox_librenms_plugin/views/base/cables_view.py index 03732ad70e..6eb54cf8f4 100644 --- a/netbox_librenms_plugin/views/base/cables_view.py +++ b/netbox_librenms_plugin/views/base/cables_view.py @@ -432,7 +432,7 @@ def post(self, request): # resolvable sync device, return an empty row rather than crashing. if selected_device.virtual_chassis: primary_device = get_librenms_sync_device(selected_device, server_key=server_key) - if primary_device is None: + if not primary_device: return JsonResponse({"status": "success", "formatted_row": formatted_row}) else: primary_device = selected_device From fe1b3341d3d12519bf1c813acdf0b9a13c55dce4 Mon Sep 17 00:00:00 2001 From: Marcin Zieba Date: Thu, 12 Mar 2026 21:07:52 +0100 Subject: [PATCH 05/71] fix(pr25-cr): address CodeRabbit review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Production fixes: - Remove role-selection blocker when refreshing existing device with no role (_refresh_existing_device only adds the blocker for new devices, matching the logic in validate_device_for_import) - Add htmx.process() after modalBody.innerHTML so HTMX attributes in dynamically-injected HTML are initialised - Return 400 for malformed JSON in SingleIPVerifyView.post (json.JSONDecodeError is a client error, not a server error) - Validate inventory items are dicts (not just that inventory is a list) in get_device_inventory / get_inventory_filtered Test improvements: - LIFO teardown in _run_validate: stop patches in reverse order so overrides are reversed before base patches - Assert match_librenms_hardware_to_device_type not called for VMs - Use InterfaceStub with fixed attributes for primary_mac_address guard test - test_invalid_object_type raises Http404 (correct production behavior) - Fix 8 filterset_form module-level patches → instance assignment so self.filterset_form(...) actually resolves to the mock - Pin VC-member delegation: assert get_virtual_chassis_member was called - Assert member.save() called in test_member_save_success - Fix settings exception test: single first() call raises (not second call) - Remove test_settings_exception_in_get_import_queryset (_get_import_queryset never calls LibreNMSSettings, mock never fired) --- .../tests/test_coverage_base_views2.py | 6 +-- .../tests/test_coverage_device_fields.py | 2 + .../tests/test_coverage_device_operations.py | 9 ++-- .../tests/test_coverage_list.py | 52 +++++++------------ 4 files changed, 30 insertions(+), 39 deletions(-) diff --git a/netbox_librenms_plugin/tests/test_coverage_base_views2.py b/netbox_librenms_plugin/tests/test_coverage_base_views2.py index 3286f93a41..5dfeb1e6b0 100644 --- a/netbox_librenms_plugin/tests/test_coverage_base_views2.py +++ b/netbox_librenms_plugin/tests/test_coverage_base_views2.py @@ -1569,8 +1569,8 @@ def test_success_with_cache_entry_updates_record(self): assert rendered_record["interface_name"] == "eth0" assert rendered_record["interface_url"] == "/interface/1/" - def test_exception_returns_500(self): - """Unhandled exception inside post() → JsonResponse 500.""" + def test_invalid_json_returns_400(self): + """Malformed JSON body → JsonResponse 400.""" import json as json_mod view = self._make_view() @@ -1578,7 +1578,7 @@ def test_exception_returns_500(self): req.body = b"not-json" # will cause json.loads to fail response = view.post(req) - assert response.status_code == 500 + assert response.status_code == 400 data = json_mod.loads(response.content) assert data["status"] == "error" diff --git a/netbox_librenms_plugin/tests/test_coverage_device_fields.py b/netbox_librenms_plugin/tests/test_coverage_device_fields.py index bbfc35c0c2..292f816f0c 100644 --- a/netbox_librenms_plugin/tests/test_coverage_device_fields.py +++ b/netbox_librenms_plugin/tests/test_coverage_device_fields.py @@ -963,6 +963,7 @@ def test_member_save_success(self): member.name = "sw-member" member.virtual_chassis = vc member.serial = "OLD" + member.save = MagicMock() DoesNotExist = type("DoesNotExist", (Exception,), {}) mock_device_cls = MagicMock() @@ -979,6 +980,7 @@ def test_member_save_success(self): view.post(req, pk=1) mock_msg.success.assert_called_once() assert member.serial == "SN100" + member.save.assert_called_once() def test_assignments_and_errors_both_reported(self): """One success + one error → both messages emitted.""" diff --git a/netbox_librenms_plugin/tests/test_coverage_device_operations.py b/netbox_librenms_plugin/tests/test_coverage_device_operations.py index 0a2a42b817..567180603b 100644 --- a/netbox_librenms_plugin/tests/test_coverage_device_operations.py +++ b/netbox_librenms_plugin/tests/test_coverage_device_operations.py @@ -253,7 +253,7 @@ def _run_validate(self, libre_device, patches_overrides=None, **kwargs): try: result = validate_device_for_import(libre_device, api=api, **kwargs) finally: - for p in base_patches: + for p in reversed(base_patches): p.stop() return result @@ -1578,13 +1578,16 @@ def test_existing_vm_skips_device_validations(self): patch("virtualization.models.VirtualMachine", new=mock_vm_model), patch("ipam.models.IPAddress"), patch("netbox_librenms_plugin.import_utils.device_operations.find_by_librenms_id", return_value=None), - patch("netbox_librenms_plugin.import_utils.device_operations.match_librenms_hardware_to_device_type"), + patch( + "netbox_librenms_plugin.import_utils.device_operations.match_librenms_hardware_to_device_type" + ) as mock_match, patch("netbox_librenms_plugin.import_utils.device_operations.find_matching_site") as mock_site, ): result = validate_device_for_import(libre_device, import_as_vm=True, api=api) - # find_matching_site should NOT be called for VMs (device-specific validation skipped) + # find_matching_site and match_librenms_hardware_to_device_type should NOT be called for VMs mock_site.assert_not_called() + mock_match.assert_not_called() # Device-specific fields are marked found=True for all VMs assert result["site"]["found"] is True assert result["device_type"]["found"] is True diff --git a/netbox_librenms_plugin/tests/test_coverage_list.py b/netbox_librenms_plugin/tests/test_coverage_list.py index 43af68df35..1148e52f79 100644 --- a/netbox_librenms_plugin/tests/test_coverage_list.py +++ b/netbox_librenms_plugin/tests/test_coverage_list.py @@ -443,23 +443,23 @@ def test_get_job_id_loads_results(self): mock_pref.return_value = None mock_form_cls = MagicMock() - mock_form = MagicMock() - mock_form.is_valid.return_value = False - mock_form_cls.return_value = mock_form - view.filterset_form = mock_form_cls + mock_form = MagicMock() + mock_form.is_valid.return_value = False + mock_form_cls.return_value = mock_form + view.filterset_form = mock_form_cls - with patch("netbox_librenms_plugin.views.imports.list.render") as mock_render: - mock_render.return_value = MagicMock() + with patch("netbox_librenms_plugin.views.imports.list.render") as mock_render: + mock_render.return_value = MagicMock() - with patch("netbox_librenms_plugin.views.imports.list.DeviceImportTable"): - with patch( - "netbox_librenms_plugin.views.imports.list.get_active_cached_searches" - ) as mock_searches: - mock_searches.return_value = [] + with patch("netbox_librenms_plugin.views.imports.list.DeviceImportTable"): + with patch( + "netbox_librenms_plugin.views.imports.list.get_active_cached_searches" + ) as mock_searches: + mock_searches.return_value = [] - with patch.object(view, "get_server_info", return_value={}): - view.get(request) - mock_load.assert_called_once_with(42) + with patch.object(view, "get_server_info", return_value={}): + view.get(request) + mock_load.assert_called_once_with(42) def test_get_invalid_job_id_logs_warning(self): """Invalid (non-integer) job_id is caught and logged.""" @@ -1095,17 +1095,7 @@ def test_get_settings_exception_in_inline_load(self): with patch.object(LibreNMSImportView, "librenms_api", new_callable=lambda: property(lambda self: mock_api)): with patch("netbox_librenms_plugin.views.imports.list.LibreNMSSettings") as mock_settings: - # First call (module-level read at top of get()) succeeds - # Second call (inline, inside the filter block) raises - first_call = [True] - - def first_then_raise(*a, **kw): - if first_call: - first_call.pop() - return None - raise Exception("DB error") - - mock_settings.objects.first.side_effect = first_then_raise + mock_settings.objects.first.side_effect = Exception("DB error") mock_settings.objects.get_or_create.return_value = (None, False) with patch("netbox_librenms_plugin.views.imports.list.get_user_pref") as mock_pref: @@ -1358,14 +1348,10 @@ def test_settings_exception_in_get_import_queryset(self): with patch("netbox_librenms_plugin.views.imports.list.get_user_pref") as mock_pref: mock_pref.return_value = None - with patch("netbox_librenms_plugin.views.imports.list.LibreNMSSettings") as mock_settings: - mock_settings.objects.first.side_effect = Exception("DB error") - - with patch("netbox_librenms_plugin.views.imports.list.cache") as mock_cache: - mock_cache.get.return_value = None - # Should not raise - result = view._get_import_queryset() - assert result == [] + with patch("netbox_librenms_plugin.views.imports.list.cache") as mock_cache: + mock_cache.get.return_value = None + result = view._get_import_queryset() + assert result == [] def test_cache_metadata_found_sets_timestamps(self): """When cache metadata is found, timestamps are set (lines 523-527).""" From 4d27ed3a0706d2258078400949f2975825905301 Mon Sep 17 00:00:00 2001 From: Marcin Zieba Date: Thu, 12 Mar 2026 21:57:21 +0100 Subject: [PATCH 06/71] fix(tests): add module-level patch/MagicMock imports to test_view_wiring --- netbox_librenms_plugin/tests/test_view_wiring.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox_librenms_plugin/tests/test_view_wiring.py b/netbox_librenms_plugin/tests/test_view_wiring.py index d872d5e879..baa7b0d1a6 100644 --- a/netbox_librenms_plugin/tests/test_view_wiring.py +++ b/netbox_librenms_plugin/tests/test_view_wiring.py @@ -7,6 +7,7 @@ import os from pathlib import Path +from unittest.mock import MagicMock, patch import pytest @@ -440,4 +441,3 @@ def test_fallback_to_api_server_key(self): # cache lookup must also use the fallback server_key cache_key_arg = mock_cache.get.call_args[0][0] assert "fallback-server" in cache_key_arg - From c737f2a23a4d48fb060ffaf48b82f0ac1473cf67 Mon Sep 17 00:00:00 2001 From: Marcin Zieba Date: Thu, 12 Mar 2026 22:15:45 +0100 Subject: [PATCH 07/71] fix(pr25-cr): address lost CR review findings --- netbox_librenms_plugin/tests/test_coverage_api.py | 10 +++++++--- .../tests/test_coverage_device_fields.py | 7 ++++++- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/netbox_librenms_plugin/tests/test_coverage_api.py b/netbox_librenms_plugin/tests/test_coverage_api.py index 2422d35e82..4374dbcde9 100644 --- a/netbox_librenms_plugin/tests/test_coverage_api.py +++ b/netbox_librenms_plugin/tests/test_coverage_api.py @@ -575,6 +575,7 @@ def test_null_devices_field_returns_none(self): """API returns {"devices": null} — TypeError must be caught, not propagate.""" api = _make_api() mock_resp = MagicMock() + mock_resp.status_code = 200 mock_resp.raise_for_status.return_value = None mock_resp.json.return_value = {"devices": None} with patch("requests.get", return_value=mock_resp): @@ -585,6 +586,7 @@ def test_empty_devices_list_returns_none(self): """API returns {"devices": []} — no match, returns None.""" api = _make_api() mock_resp = MagicMock() + mock_resp.status_code = 200 mock_resp.raise_for_status.return_value = None mock_resp.json.return_value = {"devices": []} with patch("requests.get", return_value=mock_resp): @@ -644,6 +646,8 @@ def test_stores_in_cache_when_no_cf_key(self): with patch("netbox_librenms_plugin.librenms_api.cache") as mock_cache: api._store_librenms_id(obj, 42) mock_cache.set.assert_called_once() + cache_key_used = mock_cache.set.call_args[0][0] + assert api.server_key in cache_key_used class TestParsePortVlanData: @@ -1001,16 +1005,16 @@ def test_non_200_response_returns_false(self): assert isinstance(data, str) # error message, not empty list def test_ent_physical_contained_in_filter(self): - """Line 791: ent_physical_contained_in filter applied.""" + """Line 791: ent_physical_contained_in filter exercised — API returns already-filtered list.""" api = _make_api() mock_resp = MagicMock() mock_resp.status_code = 200 mock_resp.raise_for_status.return_value = None + # The real LibreNMS API filters server-side; mock returns only the matching item. inventory = [ {"entPhysicalContainedIn": "1", "entPhysicalName": "slot1"}, - {"entPhysicalContainedIn": "2", "entPhysicalName": "slot2"}, ] - mock_resp.json.return_value = {"inventory": inventory} + mock_resp.json.return_value = {"status": "ok", "inventory": inventory} with patch("requests.get", return_value=mock_resp): ok, data = api.get_inventory_filtered(1, ent_physical_contained_in="1") assert ok is True diff --git a/netbox_librenms_plugin/tests/test_coverage_device_fields.py b/netbox_librenms_plugin/tests/test_coverage_device_fields.py index 292f816f0c..689625a2fa 100644 --- a/netbox_librenms_plugin/tests/test_coverage_device_fields.py +++ b/netbox_librenms_plugin/tests/test_coverage_device_fields.py @@ -258,6 +258,7 @@ def test_save_success_with_old_serial(self): view.post(_make_request(), pk=1) mock_msg.success.assert_called_once() assert "OLDSERIAL" in mock_msg.success.call_args[0][1] + mock_device.save.assert_called_once() def test_save_success_no_old_serial(self): view = self._view() @@ -542,6 +543,7 @@ def test_save_success_with_old_platform(self): view.post(_make_request(), pk=1) mock_msg.success.assert_called_once() assert "updated from" in mock_msg.success.call_args[0][1] + mock_device.save.assert_called_once() def test_save_success_no_old_platform(self): view = self._view() @@ -1537,13 +1539,16 @@ def test_virtualmachine_object_type_normalised(self): patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=mock_obj), patch("netbox_librenms_plugin.views.sync.device_fields.VirtualMachine", mock_vm_cls), patch("netbox_librenms_plugin.views.sync.device_fields.find_by_librenms_id", return_value=None), - patch("netbox_librenms_plugin.views.sync.device_fields.migrate_legacy_librenms_id", return_value=True), + patch( + "netbox_librenms_plugin.views.sync.device_fields.migrate_legacy_librenms_id", return_value=True + ) as mock_migrate, patch("netbox_librenms_plugin.views.sync.device_fields.transaction"), patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg, patch("netbox_librenms_plugin.views.sync.device_fields.redirect"), ): view.post(_make_request({"object_type": "virtualmachine"}), pk=1) mock_msg.success.assert_called_once() + mock_migrate.assert_called_once() def test_permission_denied(self): view = self._view() From ee2ab959227b8b8643e77a47b153860924fb6434 Mon Sep 17 00:00:00 2001 From: Marcin Zieba Date: Thu, 12 Mar 2026 22:39:06 +0100 Subject: [PATCH 08/71] fix(pr25-cr): address latest CodeRabbit review findings --- netbox_librenms_plugin/librenms_api.py | 44 ++++++++++++++----- .../tests/test_cable_verify.py | 22 +++++----- .../tests/test_coverage_base_views2.py | 10 ++--- .../tests/test_coverage_device_fields.py | 3 ++ .../tests/test_coverage_list.py | 28 ++++++------ 5 files changed, 64 insertions(+), 43 deletions(-) diff --git a/netbox_librenms_plugin/librenms_api.py b/netbox_librenms_plugin/librenms_api.py index 457eea37f7..20f6a62c90 100644 --- a/netbox_librenms_plugin/librenms_api.py +++ b/netbox_librenms_plugin/librenms_api.py @@ -211,22 +211,46 @@ def get_librenms_id(self, obj): if ip_address: librenms_id = self._normalize_librenms_id(self.get_device_id_by_ip(ip_address)) if librenms_id is not None: - self._store_librenms_id(obj, librenms_id) - return librenms_id + if isinstance(librenms_id, bool): + librenms_id = None + else: + try: + librenms_id = int(librenms_id) + except (ValueError, TypeError): + librenms_id = None + if librenms_id is not None: + self._store_librenms_id(obj, librenms_id) + return librenms_id # Try primary IP's DNS name if dns_name: librenms_id = self._normalize_librenms_id(self.get_device_id_by_hostname(dns_name)) if librenms_id is not None: - self._store_librenms_id(obj, librenms_id) - return librenms_id + if isinstance(librenms_id, bool): + librenms_id = None + else: + try: + librenms_id = int(librenms_id) + except (ValueError, TypeError): + librenms_id = None + if librenms_id is not None: + self._store_librenms_id(obj, librenms_id) + return librenms_id # Try hostname if FQDN if hostname: librenms_id = self._normalize_librenms_id(self.get_device_id_by_hostname(hostname)) if librenms_id is not None: - self._store_librenms_id(obj, librenms_id) - return librenms_id + if isinstance(librenms_id, bool): + librenms_id = None + else: + try: + librenms_id = int(librenms_id) + except (ValueError, TypeError): + librenms_id = None + if librenms_id is not None: + self._store_librenms_id(obj, librenms_id) + return librenms_id return None @@ -902,11 +926,9 @@ def list_devices(self, filters=None): return False, "Unexpected response format: invalid item shape in 'devices'" return True, devices - # LibreNMS API v0 always returns JSON objects, so result is always - # a dict here; the isinstance guard is purely defensive. - if isinstance(result, dict): - return False, result.get("message") or "Unexpected response format" - return False, "Unexpected response format" + return False, result.get("message", "Unexpected response format") if isinstance( + result, dict + ) else "Unexpected response format" except (requests.exceptions.RequestException, ValueError) as e: return False, str(e) diff --git a/netbox_librenms_plugin/tests/test_cable_verify.py b/netbox_librenms_plugin/tests/test_cable_verify.py index aae3d6b4a7..11970b8f71 100644 --- a/netbox_librenms_plugin/tests/test_cable_verify.py +++ b/netbox_librenms_plugin/tests/test_cable_verify.py @@ -180,7 +180,9 @@ def fake_process_remote(link, hostname, device_id, server_key=None): assert "cable_status" not in received_link def test_raw_keys_match_prepare_context(self): """The _raw_keys set in post() must match the one in _prepare_context().""" + import ast import inspect + import re from netbox_librenms_plugin.views.base.cables_view import BaseCableTableView, SingleCableVerifyView @@ -188,18 +190,14 @@ def test_raw_keys_match_prepare_context(self): prepare_src = inspect.getsource(BaseCableTableView._prepare_context) post_src = inspect.getsource(SingleCableVerifyView.post) - # Both should contain the same set of raw keys - expected_keys = { - "local_port", - "local_port_id", - "remote_port", - "remote_device", - "remote_port_id", - "remote_device_id", - } - for key in expected_keys: - assert f'"{key}"' in prepare_src, f"{key} missing from _prepare_context _raw_keys" - assert f'"{key}"' in post_src, f"{key} missing from post() _raw_keys" + def extract_raw_keys(src, label): + match = re.search(r"_raw_keys\s*=\s*(\{[^}]*\})", src, re.S) + assert match, f"_raw_keys missing from {label}" + return set(ast.literal_eval(match.group(1))) + + prepare_keys = extract_raw_keys(prepare_src, "_prepare_context") + post_keys = extract_raw_keys(post_src, "post()") + assert prepare_keys == post_keys, f"_raw_keys mismatch: {prepare_keys} != {post_keys}" class TestXSSEscaping: diff --git a/netbox_librenms_plugin/tests/test_coverage_base_views2.py b/netbox_librenms_plugin/tests/test_coverage_base_views2.py index 5dfeb1e6b0..cafeeb82de 100644 --- a/netbox_librenms_plugin/tests/test_coverage_base_views2.py +++ b/netbox_librenms_plugin/tests/test_coverage_base_views2.py @@ -555,15 +555,12 @@ def test_found_true_sets_remote_device_url_and_calls_enrich(self): mock_device.pk = 5 link = {"remote_port": "Gi0/1", "remote_port_id": None} - enriched = { - "remote_port": "Gi0/1", - "remote_device_url": "/dcim/devices/5/", - "netbox_remote_device_id": 5, - } with ( patch.object(view, "get_device_by_id_or_name", return_value=(mock_device, True, None)), - patch.object(view, "enrich_remote_port", return_value=enriched), + patch.object( + view, "enrich_remote_port", side_effect=lambda link, *_args, **_kwargs: dict(link) + ) as mock_enrich, patch( "netbox_librenms_plugin.views.base.cables_view.reverse", return_value="/dcim/devices/5/", @@ -573,6 +570,7 @@ def test_found_true_sets_remote_device_url_and_calls_enrich(self): assert result["remote_device_url"] == "/dcim/devices/5/" assert result["netbox_remote_device_id"] == 5 + mock_enrich.assert_called_once() def test_found_false_with_error_message(self): """found=False with error_message → cable_status set to the error.""" diff --git a/netbox_librenms_plugin/tests/test_coverage_device_fields.py b/netbox_librenms_plugin/tests/test_coverage_device_fields.py index 689625a2fa..c2572b6de4 100644 --- a/netbox_librenms_plugin/tests/test_coverage_device_fields.py +++ b/netbox_librenms_plugin/tests/test_coverage_device_fields.py @@ -1782,6 +1782,7 @@ def test_conflict_with_another_object(self): ): view.post(_make_request({"object_type": "device"}), pk=1) mock_msg.error.assert_called_once() + mock_txn.set_rollback.assert_called_once_with(True) def test_migrate_returns_false(self): """migrate_legacy_librenms_id returns False → warning.""" @@ -1849,6 +1850,7 @@ def test_validation_error_on_save(self): ): view.post(_make_request({"object_type": "device"}), pk=1) mock_msg.error.assert_called_once() + mock_txn.set_rollback.assert_called_once_with(True) def test_unexpected_error_on_save(self): view = self._view() @@ -1883,6 +1885,7 @@ def test_unexpected_error_on_save(self): ): view.post(_make_request({"object_type": "device"}), pk=1) mock_msg.error.assert_called_once() + mock_txn.set_rollback.assert_called_once_with(True) def test_success_integer_cf_value(self): """Happy path with integer cf_value → success message.""" diff --git a/netbox_librenms_plugin/tests/test_coverage_list.py b/netbox_librenms_plugin/tests/test_coverage_list.py index 1148e52f79..df66eef0a4 100644 --- a/netbox_librenms_plugin/tests/test_coverage_list.py +++ b/netbox_librenms_plugin/tests/test_coverage_list.py @@ -443,23 +443,23 @@ def test_get_job_id_loads_results(self): mock_pref.return_value = None mock_form_cls = MagicMock() - mock_form = MagicMock() - mock_form.is_valid.return_value = False - mock_form_cls.return_value = mock_form - view.filterset_form = mock_form_cls + mock_form = MagicMock() + mock_form.is_valid.return_value = False + mock_form_cls.return_value = mock_form + view.filterset_form = mock_form_cls - with patch("netbox_librenms_plugin.views.imports.list.render") as mock_render: - mock_render.return_value = MagicMock() + with patch("netbox_librenms_plugin.views.imports.list.render") as mock_render: + mock_render.return_value = MagicMock() - with patch("netbox_librenms_plugin.views.imports.list.DeviceImportTable"): - with patch( - "netbox_librenms_plugin.views.imports.list.get_active_cached_searches" - ) as mock_searches: - mock_searches.return_value = [] + with patch("netbox_librenms_plugin.views.imports.list.DeviceImportTable"): + with patch( + "netbox_librenms_plugin.views.imports.list.get_active_cached_searches" + ) as mock_searches: + mock_searches.return_value = [] - with patch.object(view, "get_server_info", return_value={}): - view.get(request) - mock_load.assert_called_once_with(42) + with patch.object(view, "get_server_info", return_value={}): + view.get(request) + mock_load.assert_called_once_with(42) def test_get_invalid_job_id_logs_warning(self): """Invalid (non-integer) job_id is caught and logged.""" From 18eaf4d8981ceb8c618a5486f75bc981f1306d90 Mon Sep 17 00:00:00 2001 From: Marcin Zieba Date: Thu, 12 Mar 2026 23:12:18 +0100 Subject: [PATCH 09/71] =?UTF-8?q?fix(pr25-cr):=20address=20new=20CodeRabbi?= =?UTF-8?q?t=20findings=20=E2=80=94=20ValueError=20handlers,=20rollback=20?= =?UTF-8?q?assertions,=20test=20hardening?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tests/test_coverage_api.py | 2 ++ .../tests/test_coverage_device_fields.py | 23 +++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/netbox_librenms_plugin/tests/test_coverage_api.py b/netbox_librenms_plugin/tests/test_coverage_api.py index 4374dbcde9..51458b69c9 100644 --- a/netbox_librenms_plugin/tests/test_coverage_api.py +++ b/netbox_librenms_plugin/tests/test_coverage_api.py @@ -74,6 +74,8 @@ def test_init_settings_import_error_defaults_to_default(self): api = LibreNMSAPI() assert api.server_key == "default" + assert api.librenms_url == "https://x.example.com" + assert api.api_token == "tok" class TestTestConnectionErrors: diff --git a/netbox_librenms_plugin/tests/test_coverage_device_fields.py b/netbox_librenms_plugin/tests/test_coverage_device_fields.py index c2572b6de4..0d72a3f043 100644 --- a/netbox_librenms_plugin/tests/test_coverage_device_fields.py +++ b/netbox_librenms_plugin/tests/test_coverage_device_fields.py @@ -258,6 +258,8 @@ def test_save_success_with_old_serial(self): view.post(_make_request(), pk=1) mock_msg.success.assert_called_once() assert "OLDSERIAL" in mock_msg.success.call_args[0][1] + assert mock_device.serial == "SN001" + mock_device.full_clean.assert_called_once() mock_device.save.assert_called_once() def test_save_success_no_old_serial(self): @@ -275,6 +277,9 @@ def test_save_success_no_old_serial(self): view.post(_make_request(), pk=1) mock_msg.success.assert_called_once() assert "set to" in mock_msg.success.call_args[0][1] + assert mock_device.serial == "SN001" + mock_device.full_clean.assert_called_once() + mock_device.save.assert_called_once() def test_save_validation_error_with_message_dict(self): from django.core.exceptions import ValidationError @@ -403,6 +408,7 @@ def test_save_success(self): view.post(_make_request(), pk=1) mock_device.full_clean.assert_called_once() mock_device.save.assert_called_once() + assert mock_device.device_type is mock_dt mock_msg.success.assert_called_once() def test_save_validation_error_with_message_dict(self): @@ -543,6 +549,8 @@ def test_save_success_with_old_platform(self): view.post(_make_request(), pk=1) mock_msg.success.assert_called_once() assert "updated from" in mock_msg.success.call_args[0][1] + assert mock_device.platform is mock_platform + mock_device.full_clean.assert_called_once() mock_device.save.assert_called_once() def test_save_success_no_old_platform(self): @@ -567,6 +575,9 @@ def test_save_success_no_old_platform(self): view.post(_make_request(), pk=1) mock_msg.success.assert_called_once() assert "set to" in mock_msg.success.call_args[0][1] + assert mock_device.platform is mock_platform + mock_device.full_clean.assert_called_once() + mock_device.save.assert_called_once() def test_save_validation_error(self): from django.core.exceptions import ValidationError @@ -727,6 +738,7 @@ def test_platform_validation_error(self): ): view.post(req, pk=1) mock_msg.error.assert_called_once() + mock_txn.set_rollback.assert_called_once_with(True) def test_device_does_not_exist_inside_transaction(self): view = self._view() @@ -755,6 +767,7 @@ def test_device_does_not_exist_inside_transaction(self): ): view.post(req, pk=1) mock_msg.error.assert_called_once() + mock_txn.set_rollback.assert_called_once_with(True) def test_device_validation_error(self): from django.core.exceptions import ValidationError @@ -788,6 +801,7 @@ def test_device_validation_error(self): ): view.post(req, pk=1) mock_msg.error.assert_called_once() + mock_txn.set_rollback.assert_called_once_with(True) def test_integrity_error(self): from django.db import IntegrityError @@ -819,6 +833,7 @@ def test_integrity_error(self): ): view.post(req, pk=1) mock_msg.error.assert_called_once() + mock_txn.set_rollback.assert_called_once_with(True) # --------------------------------------------------------------------------- @@ -1352,6 +1367,7 @@ def test_validation_error_on_save(self): mock_settings.PLUGINS_CONFIG = mock_cfg view.post(req, pk=1) mock_msg.error.assert_called_once() + mock_txn.set_rollback.assert_called_once_with(True) def test_unexpected_error_on_save(self): view = self._view() @@ -1384,6 +1400,7 @@ def test_unexpected_error_on_save(self): mock_settings.PLUGINS_CONFIG = mock_cfg view.post(req, pk=1) mock_msg.error.assert_called_once() + mock_txn.set_rollback.assert_called_once_with(True) def test_success_removes_mapping(self): """Happy path: mapping removed, last entry → cf set to None.""" @@ -1549,6 +1566,8 @@ def test_virtualmachine_object_type_normalised(self): view.post(_make_request({"object_type": "virtualmachine"}), pk=1) mock_msg.success.assert_called_once() mock_migrate.assert_called_once() + mock_locked.full_clean.assert_called_once() + mock_locked.save.assert_called_once() def test_permission_denied(self): view = self._view() @@ -1951,6 +1970,8 @@ def test_success_string_cf_value(self): ): view.post(_make_request({"object_type": "device"}), pk=1) mock_msg.success.assert_called_once() + mock_locked.full_clean.assert_called_once() + mock_locked.save.assert_called_once() def test_conflict_same_object_is_not_conflict(self): """find_by_librenms_id returns the same object → no conflict, proceeds.""" @@ -1986,6 +2007,8 @@ def test_conflict_same_object_is_not_conflict(self): ): view.post(_make_request({"object_type": "device"}), pk=1) mock_msg.success.assert_called_once() + mock_locked.full_clean.assert_called_once() + mock_locked.save.assert_called_once() # --------------------------------------------------------------------------- From a7154a2ded87a29eb2743acf5662eb4db989b902 Mon Sep 17 00:00:00 2001 From: Marcin Zieba Date: Thu, 12 Mar 2026 23:47:29 +0100 Subject: [PATCH 10/71] fix(pr25-cr): use _build_filter_hash in get_import_search_cache_key, add non-dict inventory tests, pin contained-in request param --- netbox_librenms_plugin/tests/test_coverage_api.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/netbox_librenms_plugin/tests/test_coverage_api.py b/netbox_librenms_plugin/tests/test_coverage_api.py index 51458b69c9..9b8ff68293 100644 --- a/netbox_librenms_plugin/tests/test_coverage_api.py +++ b/netbox_librenms_plugin/tests/test_coverage_api.py @@ -1017,10 +1017,13 @@ def test_ent_physical_contained_in_filter(self): {"entPhysicalContainedIn": "1", "entPhysicalName": "slot1"}, ] mock_resp.json.return_value = {"status": "ok", "inventory": inventory} - with patch("requests.get", return_value=mock_resp): + with patch("requests.get", return_value=mock_resp) as mock_get: ok, data = api.get_inventory_filtered(1, ent_physical_contained_in="1") assert ok is True assert len(data) == 1 + mock_get.assert_called_once() + _, call_kwargs = mock_get.call_args + assert call_kwargs.get("params", {}).get("entPhysicalContainedIn") == "1" def test_empty_inventory_returns_empty(self): """Line 799: when response lacks status:ok (even with an empty inventory list), returns False.""" From edf49b2453e2e17a250da4db02ce6fb227cc6d55 Mon Sep 17 00:00:00 2001 From: Marcin Zieba Date: Fri, 13 Mar 2026 11:09:18 +0100 Subject: [PATCH 11/71] chore: restore .devcontainer/README.md to develop version --- .devcontainer/README.md | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/.devcontainer/README.md b/.devcontainer/README.md index 7dc3ef967a..3560a93af3 100644 --- a/.devcontainer/README.md +++ b/.devcontainer/README.md @@ -44,20 +44,20 @@ If you need to test with a LibreNMS instance on a private network (local lab, co 1. Fork and Clone: fork the plugin repo in Github and clone locally 2. Open in VS Code and choose "Reopen in Container" (or Ctrl+Shift+P → Dev Containers: Reopen in Container) -3. Wait for setup (~5min on first run or when new NetBox image is used). The container will install the plugin and prep NetBox -4. Set up GitHub access: `gh auth login` (for pushing/pulling code changes) -5. Create your plugin config — see [LibreNMS Server configuration](#librenms-server-configuration): +2. Wait for setup (~5min on first run or when new NetBox image is used). The container will install the plugin and prep NetBox +3. Set up GitHub access: `gh auth login` (for pushing/pulling code changes) +4. Create your plugin config — see [Plugin configuration](#plugin-configuration): - `cp .devcontainer/config/plugin-config.py.example .devcontainer/config/plugin-config.py` - Edit it with your server details (tokens/URLs) -6. Start NetBox with `netbox-run` (or `netbox-run-bg` in background) (see [Commands](#-commands-aliases)) -7. Access NetBox at http://localhost:8000 +5. Start NetBox with `netbox-run` (or `netbox-run-bg` in background) (see [Commands](#-commands-aliases)) +6. Access NetBox at http://localhost:8000 - Username: `admin` - Password: `admin` ### 🔄 Code changes and Committing 8. Edit code in the repo root. Check out [contributing docs](../docs/contributing.md) 9. Use `netbox-logs` to follow log output on screen -10. Commit changes and contribute as normal by submitting a PR on GitHub. +6. Commit changes and contribute as normal by submitting a PR on GitHub. ### Quick Tips - **Auto-reload**: Works for most code changes when `DEBUG=True` @@ -241,7 +241,7 @@ After any `.env` change, rebuild the dev container to apply environment updates. ### Additional packages (including other netbox plugins) - Create `.devcontainer/extra-requirements.txt` for extra Python packages. Example: `.devcontainer/extra-requirements.txt.example`. - - After changes: run `plugin-install` to install packages, then `netbox-restart` (see [Commands](#-commands-aliases)) + - After changes: run `plugins-install` to install packages, then `netbox-restart` (see [Commands](#-commands-aliases)) ## 🔧 Git Setup @@ -259,7 +259,7 @@ The dev container includes Git and GitHub CLI pre-installed. You'll need to conf git remote -v # If it shows git@github.com:..., convert to HTTPS -git remote set-url origin https://github.com//netbox-librenms-plugin.git +git remote set-url origin https://github.com/bonzo81/netbox-librenms-plugin.git ``` ### Recommended: GitHub CLI (Easiest) @@ -347,14 +347,13 @@ fatal: Could not read from remote repository. 1. **Check remote URL** - should use HTTPS, not SSH: ```bash git remote -v - # Should show: https://github.com//netbox-librenms-plugin.git - # NOT: git@github.com:/netbox-librenms-plugin.git + # Should show: https://github.com/bonzo81/netbox-librenms-plugin.git + # NOT: git@github.com:bonzo81/netbox-librenms-plugin.git ``` 2. **Fix SSH remote URL**: - ```bash - git remote set-url origin https://github.com//netbox-librenms-plugin.git + git remote set-url origin https://github.com/bonzo81/netbox-librenms-plugin.git ``` 3. **Authenticate with GitHub CLI**: From deb6e5a4f7ab3c9b8ce1bd3291eefa3cb4fc515b Mon Sep 17 00:00:00 2001 From: Marcin Zieba Date: Fri, 13 Mar 2026 11:27:07 +0100 Subject: [PATCH 12/71] fix(pr25-cr): dedup guard order, remove duplicate ID write, validate poller_group items, fix test mocks --- .../import_utils/device_operations.py | 3 --- .../tests/test_coverage_base_views2.py | 1 + .../tests/test_coverage_device_operations.py | 10 ++++++++-- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/netbox_librenms_plugin/import_utils/device_operations.py b/netbox_librenms_plugin/import_utils/device_operations.py index 1426fe437f..2b1e5c416e 100644 --- a/netbox_librenms_plugin/import_utils/device_operations.py +++ b/netbox_librenms_plugin/import_utils/device_operations.py @@ -877,9 +877,6 @@ def import_single_device( # Create the device device = Device(**device_data) - # Store librenms_id in per-server dict format before validation so the - # mapping is present on the instance when full_clean() runs. - set_librenms_device_id(device, device_id, api.server_key) device.full_clean() device.save() diff --git a/netbox_librenms_plugin/tests/test_coverage_base_views2.py b/netbox_librenms_plugin/tests/test_coverage_base_views2.py index cafeeb82de..e75abcc1b7 100644 --- a/netbox_librenms_plugin/tests/test_coverage_base_views2.py +++ b/netbox_librenms_plugin/tests/test_coverage_base_views2.py @@ -26,6 +26,7 @@ def _mock_obj(model_name="device", pk=1, name="test-device"): obj._meta.model_name = model_name obj.pk = pk obj.name = name + obj.virtual_chassis = None return obj diff --git a/netbox_librenms_plugin/tests/test_coverage_device_operations.py b/netbox_librenms_plugin/tests/test_coverage_device_operations.py index 567180603b..c37bbe7761 100644 --- a/netbox_librenms_plugin/tests/test_coverage_device_operations.py +++ b/netbox_librenms_plugin/tests/test_coverage_device_operations.py @@ -1376,7 +1376,7 @@ def test_manual_mappings_are_applied(self, MockAPI): mock_new_device.pk = 99 with patch( "netbox_librenms_plugin.import_utils.device_operations.set_librenms_device_id" - ): + ) as mock_set_id: with patch( "netbox_librenms_plugin.import_utils.device_operations.validate_device_for_import", return_value=validation, @@ -1396,6 +1396,7 @@ def test_manual_mappings_are_applied(self, MockAPI): assert result.get("success") is True mock_new_device.full_clean.assert_called_once() mock_new_device.save.assert_called_once() + mock_set_id.assert_called_once() class TestImportSingleDeviceMoreEdgeCases: @@ -1626,11 +1627,15 @@ def test_chassis_match_overrides_hardware_match(self): vm_no_match = MagicMock() vm_no_match.objects.filter.return_value.first.return_value = None # no hostname collision + device_patch = patch("netbox_librenms_plugin.import_utils.device_operations.Device") + mock_device_cls = device_patch.start() + mock_device_cls.objects.filter.return_value.first.return_value = None + mock_device_cls.objects.exclude.return_value.first.return_value = None + patches = [ patch("netbox_librenms_plugin.import_utils.device_operations.Site"), patch("netbox_librenms_plugin.import_utils.device_operations.DeviceType"), patch("netbox_librenms_plugin.import_utils.device_operations.DeviceRole"), - patch("netbox_librenms_plugin.import_utils.device_operations.Device"), patch("netbox_librenms_plugin.import_utils.device_operations.cache"), patch("virtualization.models.VirtualMachine", new=vm_no_match), patch("ipam.models.IPAddress"), @@ -1657,5 +1662,6 @@ def test_chassis_match_overrides_hardware_match(self): finally: for p in patches: p.stop() + device_patch.stop() assert result["device_type"].get("device_type") is chassis_dt From f8dcf87d94ba26581b0a63937c300350ceaa65ee Mon Sep 17 00:00:00 2001 From: Marcin Zieba Date: Fri, 13 Mar 2026 14:46:46 +0100 Subject: [PATCH 13/71] fix(pr25-cr): fix data.get message None fallback, extract _normalize_librenms_id helper, fix Device mock chain --- netbox_librenms_plugin/librenms_api.py | 49 +++++-------------- .../tests/test_coverage_device_operations.py | 2 +- 2 files changed, 13 insertions(+), 38 deletions(-) diff --git a/netbox_librenms_plugin/librenms_api.py b/netbox_librenms_plugin/librenms_api.py index 20f6a62c90..563023261b 100644 --- a/netbox_librenms_plugin/librenms_api.py +++ b/netbox_librenms_plugin/librenms_api.py @@ -211,46 +211,22 @@ def get_librenms_id(self, obj): if ip_address: librenms_id = self._normalize_librenms_id(self.get_device_id_by_ip(ip_address)) if librenms_id is not None: - if isinstance(librenms_id, bool): - librenms_id = None - else: - try: - librenms_id = int(librenms_id) - except (ValueError, TypeError): - librenms_id = None - if librenms_id is not None: - self._store_librenms_id(obj, librenms_id) - return librenms_id + self._store_librenms_id(obj, librenms_id) + return librenms_id # Try primary IP's DNS name if dns_name: librenms_id = self._normalize_librenms_id(self.get_device_id_by_hostname(dns_name)) if librenms_id is not None: - if isinstance(librenms_id, bool): - librenms_id = None - else: - try: - librenms_id = int(librenms_id) - except (ValueError, TypeError): - librenms_id = None - if librenms_id is not None: - self._store_librenms_id(obj, librenms_id) - return librenms_id + self._store_librenms_id(obj, librenms_id) + return librenms_id # Try hostname if FQDN if hostname: librenms_id = self._normalize_librenms_id(self.get_device_id_by_hostname(hostname)) if librenms_id is not None: - if isinstance(librenms_id, bool): - librenms_id = None - else: - try: - librenms_id = int(librenms_id) - except (ValueError, TypeError): - librenms_id = None - if librenms_id is not None: - self._store_librenms_id(obj, librenms_id) - return librenms_id + self._store_librenms_id(obj, librenms_id) + return librenms_id return None @@ -258,8 +234,9 @@ def get_librenms_id(self, obj): def _normalize_librenms_id(value): """Coerce a raw LibreNMS ID value to int or None. - Booleans are rejected because bool is a subclass of int in Python, - so int(True) silently becomes 1 — a valid-looking device ID. + Treats booleans as None (LibreNMS occasionally returns True/False for + missing devices) and converts any other value to int, returning None on + failure. """ if value is None or isinstance(value, bool): return None @@ -848,11 +825,9 @@ def get_inventory_filtered(self, device_id, ent_physical_class=None, ent_physica return True, filtered - # LibreNMS API v0 always returns JSON objects, so data is always - # a dict here; the isinstance guard is purely defensive. - if isinstance(data, dict): - return False, data.get("message") or "Unexpected response format" - return False, "Unexpected response format" + return False, data.get("message") or "Unexpected response format" if isinstance( + data, dict + ) else "Unexpected response format" except (requests.exceptions.RequestException, ValueError) as e: logger.warning(f"Failed to fetch filtered inventory: {e}") diff --git a/netbox_librenms_plugin/tests/test_coverage_device_operations.py b/netbox_librenms_plugin/tests/test_coverage_device_operations.py index c37bbe7761..43e2fe8190 100644 --- a/netbox_librenms_plugin/tests/test_coverage_device_operations.py +++ b/netbox_librenms_plugin/tests/test_coverage_device_operations.py @@ -1630,7 +1630,7 @@ def test_chassis_match_overrides_hardware_match(self): device_patch = patch("netbox_librenms_plugin.import_utils.device_operations.Device") mock_device_cls = device_patch.start() mock_device_cls.objects.filter.return_value.first.return_value = None - mock_device_cls.objects.exclude.return_value.first.return_value = None + mock_device_cls.objects.filter.return_value.exclude.return_value.first.return_value = None patches = [ patch("netbox_librenms_plugin.import_utils.device_operations.Site"), From fb0c6c84da740ad8f1f9e3c3cbe8bdcc3ea46c87 Mon Sep 17 00:00:00 2001 From: Marcin Zieba Date: Fri, 13 Mar 2026 18:23:29 +0100 Subject: [PATCH 14/71] =?UTF-8?q?fix:=20CR=20findings=20=E2=80=94=20bulk?= =?UTF-8?q?=5Fimport=20refreshed=20path,=20librenms=5Fapi=20message=20fall?= =?UTF-8?q?back,=20test=20patch=20order?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - bulk_import.py: after recalculate_validation_status in the refreshed path (existing_device already set and still present), explicitly force can_import=False and is_ready=False so an existing matched device cannot flip back to import-ready state - librenms_api.py: replace result.get("message", "...") with result.get("message") or "..." pattern so null message values are covered (not just missing keys) across all affected return sites - test_coverage_device_operations.py: _stop_patches now iterates reversed(patches) to mirror proper stack teardown order --- netbox_librenms_plugin/librenms_api.py | 2 +- netbox_librenms_plugin/tests/test_coverage_device_operations.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/netbox_librenms_plugin/librenms_api.py b/netbox_librenms_plugin/librenms_api.py index 563023261b..c4cefea76c 100644 --- a/netbox_librenms_plugin/librenms_api.py +++ b/netbox_librenms_plugin/librenms_api.py @@ -901,7 +901,7 @@ def list_devices(self, filters=None): return False, "Unexpected response format: invalid item shape in 'devices'" return True, devices - return False, result.get("message", "Unexpected response format") if isinstance( + return False, result.get("message") or "Unexpected response format" if isinstance( result, dict ) else "Unexpected response format" except (requests.exceptions.RequestException, ValueError) as e: diff --git a/netbox_librenms_plugin/tests/test_coverage_device_operations.py b/netbox_librenms_plugin/tests/test_coverage_device_operations.py index 43e2fe8190..2c2848bdfb 100644 --- a/netbox_librenms_plugin/tests/test_coverage_device_operations.py +++ b/netbox_librenms_plugin/tests/test_coverage_device_operations.py @@ -799,7 +799,7 @@ def _start_patches(self, extra_patches=None): return patches, started def _stop_patches(self, patches): - for p in patches: + for p in reversed(patches): p.stop() def test_vm_librenms_id_not_int_falls_back(self): From c28e255a5362178e42e9bb96214782d3dd757dae Mon Sep 17 00:00:00 2001 From: Marcin Zieba Date: Fri, 13 Mar 2026 14:46:46 +0100 Subject: [PATCH 15/71] fix(pr25-cr): preserve can_import/is_ready after recalculate, fix data.get message fallback, extract _normalize_librenms_id helper, fix Device mock chain --- netbox_librenms_plugin/import_utils/bulk_import.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/netbox_librenms_plugin/import_utils/bulk_import.py b/netbox_librenms_plugin/import_utils/bulk_import.py index a01bc7562f..380a5ab61f 100644 --- a/netbox_librenms_plugin/import_utils/bulk_import.py +++ b/netbox_librenms_plugin/import_utils/bulk_import.py @@ -474,6 +474,10 @@ def _lookup_in_model(m): elif not actual_is_vm: validation["device_role"] = {"found": False, "role": None} recalculate_validation_status(validation, is_vm=actual_is_vm) + # Preserve non-importable state: recalculate may flip these back if no other + # issues remain, but a late-found existing match must never be import-ready. + validation["can_import"] = False + validation["is_ready"] = False except Exception as e: logger.error(f"Failed to check for newly imported device: {e}") From 1923c495b2958e60abd4ad77a4753d7babd9619a Mon Sep 17 00:00:00 2001 From: Marcin Zieba Date: Thu, 12 Mar 2026 08:21:51 +0000 Subject: [PATCH 16/71] fix(multi-server): CR review fixes, production hardening, and test coverage Production fixes: - cables_view.py: None guard for port_id/port_name in local_ports_map - actions.py: remove overly strict migration gate for migrate_librenms_id - device_fields.py: test compat fixes - jobs.py: persist use_sysname/strip_domain in FilterDevicesJob.data - librenms_api.py: int conversion guard, VLAN dict isinstance guard - bulk_import.py: _cancelled flag in result, apply_role_to_validation helper - virtual_chassis.py: server_key param for VC domain namespacing - forms.py: has_option_only bool(data) guard for empty GET forms - cache.py: sort_keys=True in metadata hash for stable cache keys - JS: server_key input selector fix, closeHtmxModal abort Test coverage: 27 new coverage files + updated existing test files --- .../tests/test_coverage_list.py | 24 +++++++++++++++---- .../tests/test_view_wiring.py | 2 +- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/netbox_librenms_plugin/tests/test_coverage_list.py b/netbox_librenms_plugin/tests/test_coverage_list.py index df66eef0a4..43af68df35 100644 --- a/netbox_librenms_plugin/tests/test_coverage_list.py +++ b/netbox_librenms_plugin/tests/test_coverage_list.py @@ -1095,7 +1095,17 @@ def test_get_settings_exception_in_inline_load(self): with patch.object(LibreNMSImportView, "librenms_api", new_callable=lambda: property(lambda self: mock_api)): with patch("netbox_librenms_plugin.views.imports.list.LibreNMSSettings") as mock_settings: - mock_settings.objects.first.side_effect = Exception("DB error") + # First call (module-level read at top of get()) succeeds + # Second call (inline, inside the filter block) raises + first_call = [True] + + def first_then_raise(*a, **kw): + if first_call: + first_call.pop() + return None + raise Exception("DB error") + + mock_settings.objects.first.side_effect = first_then_raise mock_settings.objects.get_or_create.return_value = (None, False) with patch("netbox_librenms_plugin.views.imports.list.get_user_pref") as mock_pref: @@ -1348,10 +1358,14 @@ def test_settings_exception_in_get_import_queryset(self): with patch("netbox_librenms_plugin.views.imports.list.get_user_pref") as mock_pref: mock_pref.return_value = None - with patch("netbox_librenms_plugin.views.imports.list.cache") as mock_cache: - mock_cache.get.return_value = None - result = view._get_import_queryset() - assert result == [] + with patch("netbox_librenms_plugin.views.imports.list.LibreNMSSettings") as mock_settings: + mock_settings.objects.first.side_effect = Exception("DB error") + + with patch("netbox_librenms_plugin.views.imports.list.cache") as mock_cache: + mock_cache.get.return_value = None + # Should not raise + result = view._get_import_queryset() + assert result == [] def test_cache_metadata_found_sets_timestamps(self): """When cache metadata is found, timestamps are set (lines 523-527).""" diff --git a/netbox_librenms_plugin/tests/test_view_wiring.py b/netbox_librenms_plugin/tests/test_view_wiring.py index baa7b0d1a6..d872d5e879 100644 --- a/netbox_librenms_plugin/tests/test_view_wiring.py +++ b/netbox_librenms_plugin/tests/test_view_wiring.py @@ -7,7 +7,6 @@ import os from pathlib import Path -from unittest.mock import MagicMock, patch import pytest @@ -441,3 +440,4 @@ def test_fallback_to_api_server_key(self): # cache lookup must also use the fallback server_key cache_key_arg = mock_cache.get.call_args[0][0] assert "fallback-server" in cache_key_arg + From b23234088cf130cfa644edf45f0823e050c8ca2a Mon Sep 17 00:00:00 2001 From: Marcin Zieba Date: Thu, 12 Mar 2026 08:21:51 +0000 Subject: [PATCH 17/71] fix(multi-server): CR review fixes, production hardening, and test coverage Production fixes: - cables_view.py: None guard for port_id/port_name in local_ports_map - actions.py: remove overly strict migration gate for migrate_librenms_id - device_fields.py: test compat fixes - jobs.py: persist use_sysname/strip_domain in FilterDevicesJob.data - librenms_api.py: int conversion guard, VLAN dict isinstance guard - bulk_import.py: _cancelled flag in result, apply_role_to_validation helper - virtual_chassis.py: server_key param for VC domain namespacing - forms.py: has_option_only bool(data) guard for empty GET forms - cache.py: sort_keys=True in metadata hash for stable cache keys - JS: server_key input selector fix, closeHtmxModal abort Test coverage: 27 new coverage files + updated existing test files --- .../tests/test_coverage_sync_views2.py | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/netbox_librenms_plugin/tests/test_coverage_sync_views2.py b/netbox_librenms_plugin/tests/test_coverage_sync_views2.py index e484babb10..aa3143e952 100644 --- a/netbox_librenms_plugin/tests/test_coverage_sync_views2.py +++ b/netbox_librenms_plugin/tests/test_coverage_sync_views2.py @@ -117,7 +117,7 @@ def test_valid_cable_created(self): view = object.__new__(SyncCablesView) view.require_all_permissions = MagicMock(return_value=None) - view.request = _make_request(post_data={"select": ["port1"]}) + view.request = _make_request(post_data={"select": ["port1"], "device_selection_port1": "1"}) view.get_cache_key = MagicMock(return_value="key") view._post_server_key = "default" @@ -140,12 +140,10 @@ def test_valid_cable_created(self): patch("netbox_librenms_plugin.views.sync.cables.Cable") as mock_cable_cls, patch("netbox_librenms_plugin.views.sync.cables.Interface") as mock_iface_cls, patch("netbox_librenms_plugin.views.sync.cables.transaction"), - patch("netbox_librenms_plugin.views.sync.cables.ContentType") as mock_ct, patch.object( type(view), "librenms_api", new_callable=lambda: property(lambda s: MagicMock(server_key="default")) ), ): - mock_ct.objects.get_for_model.return_value = MagicMock() mock_cache.get.return_value = {"links": [link_data]} local_iface.device_id = mock_device.id # match device_id to skip VC re-lookup mock_iface_cls.objects.get.side_effect = [local_iface, remote_iface] @@ -184,12 +182,10 @@ def test_duplicate_cable_shows_warning(self): patch("netbox_librenms_plugin.views.sync.cables.Cable") as mock_cable_cls, patch("netbox_librenms_plugin.views.sync.cables.Interface") as mock_iface_cls, patch("netbox_librenms_plugin.views.sync.cables.transaction"), - patch("netbox_librenms_plugin.views.sync.cables.ContentType") as mock_ct, patch.object( type(view), "librenms_api", new_callable=lambda: property(lambda s: MagicMock(server_key="default")) ), ): - mock_ct.objects.get_for_model.return_value = MagicMock() mock_cache.get.return_value = {"links": [link_data]} local_iface = MagicMock(pk=10) local_iface.device_id = mock_device.id # match device_id to skip VC re-lookup @@ -427,11 +423,7 @@ def test_check_existing_cable(self): local = MagicMock(pk=1) remote = MagicMock(pk=2) - with ( - patch("netbox_librenms_plugin.views.sync.cables.Cable") as mock_cable_cls, - patch("netbox_librenms_plugin.views.sync.cables.ContentType") as mock_ct, - ): - mock_ct.objects.get_for_model.return_value = MagicMock() + with patch("netbox_librenms_plugin.views.sync.cables.Cable") as mock_cable_cls: mock_cable_cls.objects.filter.return_value.exists.return_value = True result = view.check_existing_cable(local, remote) assert result is True From d6533598604b67ce31ba3f2fdd6c3206263ea44a Mon Sep 17 00:00:00 2001 From: Marcin Zieba Date: Thu, 12 Mar 2026 08:18:36 +0000 Subject: [PATCH 18/71] feat(multi-server): JSON custom field librenms_id with server management and CR fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Multi-server librenms_id as JSON custom field (server_key → device_id map) - Server management UI, migration of legacy integer IDs - CR fixes: int conversion guard, VLAN dict guard, VC domain with server_key, forms has_option_only, JS server_key selector, _cancelled flag in bulk import, cache sort_keys, role recalculate, XSS escape, bool guards - Update existing tests for multi-server behavior - test_librenms_id.py: tests for new JSON CF format and migration --- netbox_librenms_plugin/import_utils/cache.py | 2 +- .../tests/test_view_wiring.py | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/netbox_librenms_plugin/import_utils/cache.py b/netbox_librenms_plugin/import_utils/cache.py index b6860a3fea..65bd470a31 100644 --- a/netbox_librenms_plugin/import_utils/cache.py +++ b/netbox_librenms_plugin/import_utils/cache.py @@ -190,7 +190,7 @@ def get_validated_device_cache_key( ) -def get_import_device_cache_key(device_id: int | str, server_key: str = "default") -> str: +def get_import_device_cache_key(device_id: int | str, server_key: str) -> str: """ Generate cache key for raw LibreNMS device data. diff --git a/netbox_librenms_plugin/tests/test_view_wiring.py b/netbox_librenms_plugin/tests/test_view_wiring.py index d872d5e879..a2f26122e6 100644 --- a/netbox_librenms_plugin/tests/test_view_wiring.py +++ b/netbox_librenms_plugin/tests/test_view_wiring.py @@ -65,6 +65,12 @@ def test_assign_vc_serial_has_librenms_api_mixin(self): self._assert_has_api_mixin(AssignVCSerialView) + def test_convert_legacy_id_has_librenms_api_mixin(self): + from netbox_librenms_plugin.views.sync.device_fields import ConvertLegacyLibreNMSIdView + + self._assert_has_api_mixin(ConvertLegacyLibreNMSIdView) + + class TestCacheMixinWiring: """Views that cache LibreNMS data must have CacheMixin and expose get_cache_key.""" @@ -195,6 +201,18 @@ def test_update_device_serial_has_required_object_permissions(self): self._assert_has_mixins(UpdateDeviceSerialView) assert "POST" in UpdateDeviceSerialView.required_object_permissions + def test_remove_server_mapping_has_required_object_permissions(self): + from netbox_librenms_plugin.views.sync.device_fields import RemoveServerMappingView + + self._assert_has_mixins(RemoveServerMappingView) + assert "POST" in RemoveServerMappingView.required_object_permissions + + def test_convert_legacy_id_has_required_object_permissions(self): + from netbox_librenms_plugin.views.sync.device_fields import ConvertLegacyLibreNMSIdView + + self._assert_has_mixins(ConvertLegacyLibreNMSIdView) + assert "POST" in ConvertLegacyLibreNMSIdView.required_object_permissions + def test_delete_interfaces_has_required_object_permissions(self): from dcim.models import Interface from virtualization.models import VMInterface From 1bdca701a45b7ca11a0c1e540fb783c1226ae425 Mon Sep 17 00:00:00 2001 From: Marcin Zieba Date: Thu, 12 Mar 2026 09:04:29 +0000 Subject: [PATCH 19/71] fix: address CR review findings from PR #41 - test_integration_sync: fix mock endpoint path for test_null_inventory_returns_false; get_device_inventory() calls /api/v0/inventory/{id}/all, not /api/v0/devices/{id}/inventory - librenms_sync_view: skip serial gate for VMs when computing librenms_id_serial_confirmed; VirtualMachine has no serial field so the gate always blocked VM legacy-ID conversion - device_fields: skip serial check for VMs in ConvertLegacyLibreNMSIdView; same root cause - actions: expand VM action guard from migrate_librenms_id-only to also allow sync_name; hostname sync (sync_name) is valid for virtual machines --- netbox_librenms_plugin/views/sync/device_fields.py | 1 + 1 file changed, 1 insertion(+) diff --git a/netbox_librenms_plugin/views/sync/device_fields.py b/netbox_librenms_plugin/views/sync/device_fields.py index 4a78581ed2..8b0d69a7de 100644 --- a/netbox_librenms_plugin/views/sync/device_fields.py +++ b/netbox_librenms_plugin/views/sync/device_fields.py @@ -611,6 +611,7 @@ def post(self, request, pk): netbox_serial = (getattr(obj, "serial", None) or "").strip() # VMs have no serial field in NetBox; skip the serial gate for them. is_vm = object_type == "vm" + is_vm = object_type == "vm" if not is_vm and (not netbox_serial or not librenms_serial or netbox_serial != librenms_serial): messages.error( request, From 0d4b605f60f4c5cbd8eca1dff23837061ed423f7 Mon Sep 17 00:00:00 2001 From: Marcin Zieba Date: Thu, 12 Mar 2026 08:21:51 +0000 Subject: [PATCH 20/71] fix(multi-server): CR review fixes, production hardening, and test coverage Production fixes: - cables_view.py: None guard for port_id/port_name in local_ports_map - actions.py: remove overly strict migration gate for migrate_librenms_id - device_fields.py: test compat fixes - jobs.py: persist use_sysname/strip_domain in FilterDevicesJob.data - librenms_api.py: int conversion guard, VLAN dict isinstance guard - bulk_import.py: _cancelled flag in result, apply_role_to_validation helper - virtual_chassis.py: server_key param for VC domain namespacing - forms.py: has_option_only bool(data) guard for empty GET forms - cache.py: sort_keys=True in metadata hash for stable cache keys - JS: server_key input selector fix, closeHtmxModal abort Test coverage: 27 new coverage files + updated existing test files --- netbox_librenms_plugin/views/sync/device_fields.py | 1 - 1 file changed, 1 deletion(-) diff --git a/netbox_librenms_plugin/views/sync/device_fields.py b/netbox_librenms_plugin/views/sync/device_fields.py index 8b0d69a7de..4a78581ed2 100644 --- a/netbox_librenms_plugin/views/sync/device_fields.py +++ b/netbox_librenms_plugin/views/sync/device_fields.py @@ -611,7 +611,6 @@ def post(self, request, pk): netbox_serial = (getattr(obj, "serial", None) or "").strip() # VMs have no serial field in NetBox; skip the serial gate for them. is_vm = object_type == "vm" - is_vm = object_type == "vm" if not is_vm and (not netbox_serial or not librenms_serial or netbox_serial != librenms_serial): messages.error( request, From 5bb746e8c84d53c240267cf1eaa3ceea995aa9d2 Mon Sep 17 00:00:00 2001 From: Marcin Zieba Date: Thu, 12 Mar 2026 08:26:31 +0000 Subject: [PATCH 21/71] feat(inventory): modules/inventory sync tab with rules, mappings, and CR fixes - Modules sync tab: ENTITY-MIB based module comparison and install/replace - Inventory ignore rules, module bay/type mappings, normalization rules - Migration 0009: InventoryIgnoreRule, ModuleBayMapping, ModuleTypeMapping models - DeviceTypeMapping model enhancements - CR fixes: chassis None guard, existing-VM else branch, JS CSRF/order fixes, module tab pane model_name guard, IP sync dup ID guard - Additional test coverage for inventory sync, modules view, mappings - Updated existing tests for inventory-aware behavior - Removed .claude worktree artifacts accidentally committed in prior sessions --- .devcontainer/README.md | 5 +- .devcontainer/scripts/diagnose.sh | 1 + .devcontainer/scripts/load-aliases.sh | 1 + .devcontainer/scripts/setup.sh | 1 + .devcontainer/scripts/start-netbox.sh | 2 +- .devcontainer/scripts/welcome.sh | 3 +- .github/workflows/lint-format.yaml | 28 +- .github/workflows/test.yaml | 3 +- .gitignore | 2 +- contrib/README.md | 30 + contrib/device_type_mappings.yaml | 73 + contrib/interface_type_mappings.yaml | 70 + contrib/inventory_ignore_rules.yaml | 94 + contrib/module_bay_mappings.yaml | 216 ++ contrib/module_type_mappings.yaml | 332 ++ contrib/normalization_rules.yaml | 61 + netbox_librenms_plugin/__init__.py | 29 +- netbox_librenms_plugin/api/serializers.py | 76 +- netbox_librenms_plugin/api/urls.py | 5 + netbox_librenms_plugin/api/views.py | 69 +- netbox_librenms_plugin/filters.py | 59 +- netbox_librenms_plugin/forms.py | 283 +- .../import_utils/bulk_import.py | 25 +- netbox_librenms_plugin/import_utils/cache.py | 3 +- .../import_utils/virtual_chassis.py | 1 - .../import_utils/vm_operations.py | 2 +- netbox_librenms_plugin/jobs.py | 3 - netbox_librenms_plugin/librenms_api.py | 49 +- .../migrations/0009_inventory_models.py | 269 ++ netbox_librenms_plugin/models.py | 361 ++ netbox_librenms_plugin/navigation.py | 85 + .../js/librenms_sync.js | 182 +- netbox_librenms_plugin/tables/mappings.py | 199 +- netbox_librenms_plugin/tables/modules.py | 261 ++ .../netbox_librenms_plugin/_module_sync.html | 28 + .../_module_sync_content.html | 42 + .../devicetypemapping.html | 28 + .../devicetypemapping_list.html | 12 + .../htmx/device_validation_details.html | 7 +- .../htmx/module_mismatch_modal.html | 110 + .../inc/_module_sync.html | 28 + .../inventoryignorerule.html | 36 + .../inventoryignorerule_list.html | 22 + .../librenms_sync_base.html | 33 + .../modulebaymapping.html | 32 + .../modulebaymapping_list.html | 12 + .../moduletypemapping.html | 28 + .../moduletypemapping_list.html | 12 + .../normalizationrule.html | 34 + .../normalizationrule_list.html | 16 + .../tests/mock_librenms_server.py | 9 +- .../tests/test_background_jobs.py | 206 +- .../tests/test_coverage_actions.py | 31 + .../tests/test_coverage_bulk_import.py | 2290 +++++++++++++ .../tests/test_coverage_devices.py | 886 +++++ .../tests/test_coverage_filters.py | 2 + .../tests/test_coverage_list.py | 28 + .../tests/test_coverage_mixins.py | 41 +- .../tests/test_coverage_sync_view.py | 171 + .../tests/test_coverage_sync_views.py | 4 +- .../tests/test_coverage_sync_views2.py | 5 +- .../tests/test_import_utils.py | 2930 +++++++++++------ netbox_librenms_plugin/tests/test_init.py | 7 +- .../tests/test_integration_virtual_chassis.py | 59 + .../tests/test_librenms_id.py | 40 +- .../tests/test_module_replace.py | 383 +++ .../tests/test_modules_view.py | 1181 +++++++ .../tests/test_permissions.py | 2 +- .../tests/test_sync_devices.py | 19 +- .../tests/test_sync_modules.py | 1057 ++++++ .../tests/test_sync_view_mismatch.py | 168 +- .../tests/test_tables_modules.py | 597 ++++ netbox_librenms_plugin/tests/test_utils.py | 68 + .../tests/test_view_wiring.py | 11 +- .../tests/test_vm_operations.py | 150 +- netbox_librenms_plugin/urls.py | 307 +- netbox_librenms_plugin/utils.py | 166 +- netbox_librenms_plugin/views/__init__.py | 50 + .../views/base/cables_view.py | 36 +- .../views/base/modules_view.py | 1002 ++++++ .../views/imports/actions.py | 23 +- netbox_librenms_plugin/views/mapping_views.py | 342 +- .../views/object_sync/__init__.py | 1 + .../views/object_sync/devices.py | 29 +- netbox_librenms_plugin/views/sync/devices.py | 11 +- netbox_librenms_plugin/views/sync/modules.py | 807 +++++ pyproject.toml | 10 +- tests/e2e/__init__.py | 0 tests/e2e/conftest.py | 6 + tests/e2e/test_module_install.py | 331 ++ 90 files changed, 15582 insertions(+), 1247 deletions(-) create mode 100644 contrib/README.md create mode 100644 contrib/device_type_mappings.yaml create mode 100644 contrib/interface_type_mappings.yaml create mode 100644 contrib/inventory_ignore_rules.yaml create mode 100644 contrib/module_bay_mappings.yaml create mode 100644 contrib/module_type_mappings.yaml create mode 100644 contrib/normalization_rules.yaml create mode 100644 netbox_librenms_plugin/migrations/0009_inventory_models.py create mode 100644 netbox_librenms_plugin/tables/modules.py create mode 100644 netbox_librenms_plugin/templates/netbox_librenms_plugin/_module_sync.html create mode 100644 netbox_librenms_plugin/templates/netbox_librenms_plugin/_module_sync_content.html create mode 100644 netbox_librenms_plugin/templates/netbox_librenms_plugin/devicetypemapping.html create mode 100644 netbox_librenms_plugin/templates/netbox_librenms_plugin/devicetypemapping_list.html create mode 100644 netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/module_mismatch_modal.html create mode 100644 netbox_librenms_plugin/templates/netbox_librenms_plugin/inc/_module_sync.html create mode 100644 netbox_librenms_plugin/templates/netbox_librenms_plugin/inventoryignorerule.html create mode 100644 netbox_librenms_plugin/templates/netbox_librenms_plugin/inventoryignorerule_list.html create mode 100644 netbox_librenms_plugin/templates/netbox_librenms_plugin/modulebaymapping.html create mode 100644 netbox_librenms_plugin/templates/netbox_librenms_plugin/modulebaymapping_list.html create mode 100644 netbox_librenms_plugin/templates/netbox_librenms_plugin/moduletypemapping.html create mode 100644 netbox_librenms_plugin/templates/netbox_librenms_plugin/moduletypemapping_list.html create mode 100644 netbox_librenms_plugin/templates/netbox_librenms_plugin/normalizationrule.html create mode 100644 netbox_librenms_plugin/templates/netbox_librenms_plugin/normalizationrule_list.html create mode 100644 netbox_librenms_plugin/tests/test_coverage_bulk_import.py create mode 100644 netbox_librenms_plugin/tests/test_coverage_devices.py create mode 100644 netbox_librenms_plugin/tests/test_module_replace.py create mode 100644 netbox_librenms_plugin/tests/test_modules_view.py create mode 100644 netbox_librenms_plugin/tests/test_sync_modules.py create mode 100644 netbox_librenms_plugin/tests/test_tables_modules.py create mode 100644 netbox_librenms_plugin/views/base/modules_view.py create mode 100644 netbox_librenms_plugin/views/sync/modules.py create mode 100644 tests/e2e/__init__.py create mode 100644 tests/e2e/conftest.py create mode 100644 tests/e2e/test_module_install.py diff --git a/.devcontainer/README.md b/.devcontainer/README.md index 3560a93af3..2f20ba1c2d 100644 --- a/.devcontainer/README.md +++ b/.devcontainer/README.md @@ -49,8 +49,9 @@ If you need to test with a LibreNMS instance on a private network (local lab, co 4. Create your plugin config — see [Plugin configuration](#plugin-configuration): - `cp .devcontainer/config/plugin-config.py.example .devcontainer/config/plugin-config.py` - Edit it with your server details (tokens/URLs) -5. Start NetBox with `netbox-run` (or `netbox-run-bg` in background) (see [Commands](#-commands-aliases)) -6. Access NetBox at http://localhost:8000 +6. Start NetBox with `netbox-run` (or `netbox-run-bg` in background) (see [Commands](#-commands-aliases)) +7. Access NetBox at http://localhost:8000 + - If using GitHub Codespaces, use the forwarded port URL from the Ports panel instead of `http://localhost:8000`. - Username: `admin` - Password: `admin` diff --git a/.devcontainer/scripts/diagnose.sh b/.devcontainer/scripts/diagnose.sh index 133e6ca97f..be7596d699 100755 --- a/.devcontainer/scripts/diagnose.sh +++ b/.devcontainer/scripts/diagnose.sh @@ -1,4 +1,5 @@ #!/bin/bash +# netbox-librenms-plugin devcontainer script echo "🔍 DevContainer Startup Diagnostics" echo "==================================" diff --git a/.devcontainer/scripts/load-aliases.sh b/.devcontainer/scripts/load-aliases.sh index 65149d6198..feac6ee98f 100755 --- a/.devcontainer/scripts/load-aliases.sh +++ b/.devcontainer/scripts/load-aliases.sh @@ -1,4 +1,5 @@ #!/bin/bash +# netbox-librenms-plugin devcontainer script # Quick alias loader for current session # Usage: source .devcontainer/scripts/load-aliases.sh diff --git a/.devcontainer/scripts/setup.sh b/.devcontainer/scripts/setup.sh index 588fe652ab..7f4278fd46 100755 --- a/.devcontainer/scripts/setup.sh +++ b/.devcontainer/scripts/setup.sh @@ -1,4 +1,5 @@ #!/bin/bash +# netbox-librenms-plugin devcontainer script set -e echo "🚀 Setting up NetBox LibreNMS Plugin development environment..." diff --git a/.devcontainer/scripts/start-netbox.sh b/.devcontainer/scripts/start-netbox.sh index 789dcb845a..d5e4796600 100755 --- a/.devcontainer/scripts/start-netbox.sh +++ b/.devcontainer/scripts/start-netbox.sh @@ -1,4 +1,5 @@ #!/bin/bash +# netbox-librenms-plugin devcontainer script # Check if we should run in background or foreground BACKGROUND=false @@ -18,7 +19,6 @@ if [ "$CODESPACES" = "true" ] && [ -n "$CODESPACE_NAME" ]; then echo "🔗 GitHub Codespaces detected" else ACCESS_URL="http://localhost:8000" - echo "🐛 Debug: ACCESS_URL is set to: $ACCESS_URL" fi # Load shared process management helpers diff --git a/.devcontainer/scripts/welcome.sh b/.devcontainer/scripts/welcome.sh index 9328d663aa..e273313766 100755 --- a/.devcontainer/scripts/welcome.sh +++ b/.devcontainer/scripts/welcome.sh @@ -1,4 +1,5 @@ #!/bin/bash +# netbox-librenms-plugin devcontainer script # Ensure aliases are available in the postAttach terminal session source "$(dirname "$0")/load-aliases.sh" 2>/dev/null @@ -44,7 +45,7 @@ if [ -n "$CODESPACES" ]; then echo " 💡 Click the link in the Ports panel or look for the 'Open in Browser' button" else echo "🖥️ Local Development Environment:" - echo " NetBox will be available at: http://localhost:8000 (paste into you browser)" + echo " NetBox will be available at: http://localhost:8000 (paste into your browser)" fi echo "" diff --git a/.github/workflows/lint-format.yaml b/.github/workflows/lint-format.yaml index f60697dcbc..b665ab74d3 100644 --- a/.github/workflows/lint-format.yaml +++ b/.github/workflows/lint-format.yaml @@ -2,13 +2,7 @@ name: Lint and Format on: push: - branches: - - master - - develop pull_request: - branches: - - master - - develop jobs: format-and-lint: @@ -20,8 +14,7 @@ jobs: - name: Set up Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: - python-version: '3.9' - cache: 'pip' + python-version: '3.12' - name: Install dependencies run: | @@ -29,22 +22,7 @@ jobs: pip install ruff - name: Run Ruff linting - run: | - echo "::group::Ruff Linting" - ruff check . --output-format=github - echo "::endgroup::" + run: ruff check . - name: Run Ruff formatting check - run: | - echo "::group::Ruff Formatting" - ruff format --check . - echo "::endgroup::" - - - name: Report formatting issues - if: failure() - run: | - echo "::error::Formatting or linting issues detected!" - echo "To fix locally, run:" - echo " ruff check --fix ." - echo " ruff format ." - echo "Then commit and push the changes." + run: ruff format --check . diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 8d4e5e6fb0..2975d28978 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -82,7 +82,8 @@ jobs: - name: Upload coverage report uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - if: matrix.python-version == '3.12' + if: always() && matrix.python-version == '3.12' with: name: coverage-report path: netbox-librenms-plugin/coverage_html/ + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index 25c298c23b..2b5d15adca 100644 --- a/.gitignore +++ b/.gitignore @@ -164,7 +164,6 @@ pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ -coverage_html/ .tox/ .nox/ .coverage @@ -290,3 +289,4 @@ cython_debug/ ca-bundle.crt *.pem .github/hooks/ +.claude/ diff --git a/contrib/README.md b/contrib/README.md new file mode 100644 index 0000000000..8cffa798c7 --- /dev/null +++ b/contrib/README.md @@ -0,0 +1,30 @@ +# Contrib: Example Mapping Files + +This directory contains example YAML mapping files for bulk import into the +NetBox LibreNMS Plugin. Each file can be imported via the plugin's bulk import +feature in the NetBox UI. + +## How to Import + +1. Navigate to the mapping page (e.g., **LibreNMS → Device Type Mappings**) +2. Click the **Import** button (upload icon) in the top right +3. Select **YAML** format +4. Paste the contents of the relevant YAML file +5. Click **Submit** + +## Available Mappings + +| File | Description | +|------|-------------| +| `interface_type_mappings.yaml` | Maps LibreNMS interface types + speeds to NetBox interface types | +| `device_type_mappings.yaml` | Maps LibreNMS hardware strings to NetBox device types | +| `module_type_mappings.yaml` | Maps LibreNMS inventory model names to NetBox module types (incl. transceivers) | +| `module_bay_mappings.yaml` | Maps LibreNMS inventory container names to NetBox module bay names | +| `normalization_rules.yaml` | Regex-based string normalization applied before module type/bay lookups | +| `inventory_ignore_rules.yaml` | Suppresses phantom ENTITY-MIB entries (e.g. Cisco IOS-XR IDPROM artefacts) | + +## Customisation + +These files are **examples** — adjust values to match the device types, module +types, and interface types defined in your NetBox instance. The `netbox_*` +fields must reference objects that already exist in your NetBox. diff --git a/contrib/device_type_mappings.yaml b/contrib/device_type_mappings.yaml new file mode 100644 index 0000000000..2dec241524 --- /dev/null +++ b/contrib/device_type_mappings.yaml @@ -0,0 +1,73 @@ +# Device Type Mappings +# +# Maps LibreNMS hardware strings to NetBox device types. +# Import via: LibreNMS Plugin > Device Type Mappings > Import +# +# Fields: +# librenms_hardware — Hardware string exactly as shown in LibreNMS +# netbox_device_type — NetBox DeviceType (matched by model name or ID) +# description — Optional note +# +# The librenms_hardware value is matched case-insensitively. +# These mappings are checked BEFORE the built-in part_number/model fallback. + +# Juniper — LibreNMS reports verbose marketing names +- librenms_hardware: "Juniper MX480 Internet Backbone Router" + netbox_device_type: "MX480" + description: "Juniper MX480 chassis" + +- librenms_hardware: "Juniper MX960 Internet Backbone Router" + netbox_device_type: "MX960" + description: "Juniper MX960 chassis" + +- librenms_hardware: "Juniper MX304 Edge Router" + netbox_device_type: "MX304" + description: "Juniper MX304 edge router" + +- librenms_hardware: "JNP10008 [PTX10008]" + netbox_device_type: "PTX10008" + description: "Juniper PTX10008 core router" + +- librenms_hardware: "JNP7100-32C [ACX7100-32C]" + netbox_device_type: "ACX7100-32C" + description: "Juniper ACX7100-32C" + +- librenms_hardware: "JNP7024 [ACX7024]" + netbox_device_type: "ACX7024" + description: "Juniper ACX7024" + +- librenms_hardware: "Juniper JNP10008 Internet Backbone Router" + netbox_device_type: "PTX10008" + description: "Juniper PTX10008 (alternate hardware string)" + +- librenms_hardware: "Juniper VRR Internet Backbone Router" + netbox_device_type: "VRR" + description: "Juniper Virtual Route Reflector" + +# Nokia — model string matches directly in most cases +- librenms_hardware: "7750 SR-7s" + netbox_device_type: "7750 SR-7s" + description: "Nokia 7750 SR-7s service router" + +# Cisco — often matches by part_number but not always +- librenms_hardware: "WS-C4900M" + netbox_device_type: "WS-C4900M" + description: "Cisco Catalyst 4900M" + +# Cisco IOS XR +- librenms_hardware: "8201-SYS" + netbox_device_type: "8201" + description: "Cisco 8201 (hardware string differs from model)" + +# UfiSpace — LibreNMS reports SONiC/ONIE platform names +- librenms_hardware: "x86-64-ufispace-s9610-36d-r0" + netbox_device_type: "S9610-36D" + description: "UfiSpace S9610-36D" + +- librenms_hardware: "x86-64-ufispace-s9610-46dx-r0" + netbox_device_type: "S9610-46DX" + description: "UfiSpace S9610-46DX" + +- librenms_hardware: "x86-64-ufispace-s9700-53dx-r9" + netbox_device_type: "S9700-53DX" + description: "UfiSpace S9700-53DX" diff --git a/contrib/interface_type_mappings.yaml b/contrib/interface_type_mappings.yaml new file mode 100644 index 0000000000..19db2a1fcf --- /dev/null +++ b/contrib/interface_type_mappings.yaml @@ -0,0 +1,70 @@ +# Interface Type Mappings +# +# Maps LibreNMS interface types (and optional speeds) to NetBox interface types. +# Import via: LibreNMS Plugin > Interface Mappings > Import +# +# Fields: +# librenms_type — IANA ifType string from LibreNMS (e.g. ethernetCsmacd) +# librenms_speed — Speed in Kbps (optional, null matches any speed) +# netbox_type — NetBox InterfaceTypeChoices slug +# description — Optional note +# +# Common NetBox interface type slugs: +# 1000base-t, 10gbase-t, 10gbase-x-sfpp, 25gbase-x-sfp28, +# 40gbase-x-qsfpp, 100gbase-x-qsfp28, 400gbase-x-qsfpdd, +# ieee802.11ax, lag, virtual, other + +- librenms_type: ethernetCsmacd + librenms_speed: 1000000 + netbox_type: 1000base-t + description: "1G Ethernet copper" + +- librenms_type: ethernetCsmacd + librenms_speed: 10000000 + netbox_type: 10gbase-x-sfpp + description: "10G Ethernet SFP+" + +- librenms_type: ethernetCsmacd + librenms_speed: 25000000 + netbox_type: 25gbase-x-sfp28 + description: "25G Ethernet SFP28" + +- librenms_type: ethernetCsmacd + librenms_speed: 40000000 + netbox_type: 40gbase-x-qsfpp + description: "40G Ethernet QSFP+" + +- librenms_type: ethernetCsmacd + librenms_speed: 100000000 + netbox_type: 100gbase-x-qsfp28 + description: "100G Ethernet QSFP28" + +- librenms_type: ethernetCsmacd + librenms_speed: 400000000 + netbox_type: 400gbase-x-qsfpdd + description: "400G Ethernet QSFP-DD" + +- librenms_type: ieee8023adLag + librenms_speed: + netbox_type: lag + description: "LACP/LAG aggregation" + +- librenms_type: propVirtual + librenms_speed: + netbox_type: virtual + description: "Virtual/loopback interface" + +- librenms_type: softwareLoopback + librenms_speed: + netbox_type: virtual + description: "Software loopback" + +- librenms_type: tunnel + librenms_speed: + netbox_type: virtual + description: "Tunnel interface" + +- librenms_type: l2vlan + librenms_speed: + netbox_type: virtual + description: "VLAN interface" diff --git a/contrib/inventory_ignore_rules.yaml b/contrib/inventory_ignore_rules.yaml new file mode 100644 index 0000000000..d6c80d7bf0 --- /dev/null +++ b/contrib/inventory_ignore_rules.yaml @@ -0,0 +1,94 @@ +# Inventory Ignore Rules — filter ENTITY-MIB items during module sync +# +# Two actions are supported: +# skip — remove the item from the sync table entirely +# transparent — hide the item's row but promote its ENTITY-MIB children to +# device-level bay matching (use for embedded/fixed-chassis modules) +# +# Match types: +# ends_with | starts_with | contains | regex +# — compare entPhysicalName +# serial_matches_device — compare entPhysicalSerialNum to the NetBox device's +# own serial number (no pattern needed) +# +# Import via: LibreNMS Plugin → Settings → Inventory Ignore Rules → Import +# +# Additional fields: +# require_serial_match_parent: +# true — (name-based rules only) only apply if the item's serial matches +# any ancestor entity's serial in the ENTITY-MIB tree +# false — apply unconditionally on name match alone +# enabled: true | false +# description: optional notes +# +# Serial-match ancestor walk (name-based rules only): +# When require_serial_match_parent is true the plugin walks up the ENTITY-MIB +# ancestor chain until it finds a non-empty serial. If that serial equals the +# item's serial the rule fires. This handles multi-level hierarchies, e.g. +# Cisco IOS-XR: IDPROM → Mother Board [empty serial] → RP module. +# +# Both rules below are also created automatically by migration 0014. Import them +# only if you wiped the table or need to replicate settings across instances. + +# ─── Cisco IOS-XR — IDPROM entries (action=skip) ───────────────────────────── +# IOS-XR reports each hardware component's EEPROM as a child entity whose name +# ends in "-IDPROM". These share the same model+serial as the parent and are not +# installable hardware. +# +# Hierarchy example (Cisco 8201-SYS): +# 0/RP0/CPU0 (serial FOC2418NHRK) +# └── 0/RP0/CPU0-Mother Board (serial empty) +# └── 0/RP0/CPU0-Base Board IDPROM (serial FOC2418NHRK) ← SKIP +# Optics0/0/0/0 (serial SN123) +# └── Optics0/0/0/0-IDPROM (serial SN123) ← SKIP + +- name: "Cisco IOS-XR IDPROM entries" + match_type: ends_with + pattern: "IDPROM" + action: skip + require_serial_match_parent: true + enabled: true + description: > + Cisco IOS-XR reports every hardware component's EEPROM as a child entity + whose entPhysicalName ends in "IDPROM". These entries duplicate the parent + module's serial number and are not real installable modules. + The serial-match guard ensures only genuine EEPROM duplicates are skipped — + a module whose name happens to end in "IDPROM" but has a different serial + will not be filtered. + +# ─── Fixed-chassis embedded RP (action=transparent) ────────────────────────── +# Fixed-form routers (e.g. Cisco 8201-SYS, 8101-32FH, Juniper PTX10001-36MR) +# report their built-in RP/system-board as an ENTITY-MIB module whose serial +# number equals the chassis/device serial. The RP is NOT a removable FRU — +# it IS the device itself. Marking it "transparent" hides the RP row but lets +# its ENTITY-MIB children (transceivers, fans, PSUs) fall through to device- +# level bay matching. +# +# Detection signal: entPhysicalSerialNum == NetBox Device.serial +# +# Hierarchy for Cisco 8201-SYS (device serial = FOC2418NHRK): +# Rack 0-Control Card Slot 0 (container) +# └── 0/RP0/CPU0 (serial FOC2418NHRK) ← TRANSPARENT (= device serial) +# └── Optics Controller containers +# └── 0/RP0/CPU0-QSFP bay N +# └── Optics0/0/0/N (transceiver) ← becomes device-level bay match +# +# The 8201-SYS device type should have device-level bays for: +# Optics0/0/0/0–23 (400GE QSFP-DD) +# HundredGigE0/0/0/24–35 (100GE QSFP28) +# 0/FT0–4 (fans) +# 0/PM0–1 (PSUs) +# NO bay for 0/RP0/CPU0 — the RP is the device, not a pluggable module. + +- name: "Embedded RP / fixed-chassis system board" + match_type: serial_matches_device + pattern: "" + action: transparent + require_serial_match_parent: false + enabled: true + description: > + Fixed-form routers report the built-in RP as an ENTITY-MIB module whose + serial number equals the device's own serial. Marking it transparent hides + the RP row in the sync table while promoting its children (transceivers, + fans, PSUs) to device-level bay matching. No pattern is needed — detection + is purely serial-based. diff --git a/contrib/module_bay_mappings.yaml b/contrib/module_bay_mappings.yaml new file mode 100644 index 0000000000..64c063176a --- /dev/null +++ b/contrib/module_bay_mappings.yaml @@ -0,0 +1,216 @@ +# Module Bay Mappings - Map LibreNMS inventory container names to NetBox module bay names +# +# These mappings replace heuristic matching between LibreNMS inventory and NetBox module bays. +# Import via: LibreNMS Plugin → Module Bay Mappings → Import +# +# Fields: +# librenms_name: LibreNMS entPhysicalName or container name (exact match or regex) +# librenms_class: Optional entPhysicalClass filter (powerSupply, fan, module, etc.) +# Leave empty for class-independent mappings +# netbox_bay_name: Target NetBox module bay name (supports \1, \2 backreferences with regex) +# is_regex: Set to true to treat librenms_name as a Python regex pattern +# description: Optional description +# +# Regex patterns use Python re.fullmatch() — the pattern must match the entire string. +# Backreferences (\1, \2) in netbox_bay_name reference capture groups in the pattern. + +# ─── Regex Patterns ────────────────────────────────────────────────────────── +# These patterns replace many individual exact-match entries. + +# Arcos/UfiSpace: sfpN → Transceiver N (covers sfp0 through sfp53+) +- librenms_name: "^sfp(\\d+)$" + netbox_bay_name: "Transceiver \\1" + is_regex: true + description: "Arcos sfpN → Transceiver N" + +# Cisco X2: Port Container slot/port → X2 Port port +- librenms_name: "^Port Container (\\d+)/(\\d+)$" + netbox_bay_name: "X2 Port \\2" + is_regex: true + description: "Cisco X2 Port Container → X2 Port N" + +# Cisco modules: Linecard/Supervisor(slot N) → Slot N +- librenms_name: "^Linecard\\(slot (\\d+)\\)$" + librenms_class: "module" + netbox_bay_name: "Slot \\1" + is_regex: true + description: "Cisco Linecard slot → Slot N" +- librenms_name: "^Supervisor\\(slot (\\d+)\\)$" + librenms_class: "module" + netbox_bay_name: "Slot \\1" + is_regex: true + description: "Cisco Supervisor slot → Slot N" + +# Generic power supplies and fans +- librenms_name: "^Power Supply (\\d+)$" + librenms_class: "powerSupply" + netbox_bay_name: "PS\\1" + is_regex: true + description: "Power Supply N → PSN" +- librenms_name: "^FanTray (\\d+)$" + librenms_class: "fan" + netbox_bay_name: "Fan Tray \\1" + is_regex: true + description: "FanTray N → Fan Tray N" + +# Nokia 7750 SR chassis fans and power modules +- librenms_name: "^Chassis 1 Fan (\\d+)$" + librenms_class: "fan" + netbox_bay_name: "Fan \\1" + is_regex: true + description: "Nokia chassis fan → Fan N" +- librenms_name: "^Chassis 1 PowShelf 1 PM (\\d+)$" + librenms_class: "powerSupply" + netbox_bay_name: "PM \\1" + is_regex: true + description: "Nokia power module → PM N" + +# Nokia MDA and XIOM sub-module bays +# Bay names resolve from {module}/N templates: IOM Slot 1 pos=1 → bay {module}/1 = 1/1 +- librenms_name: "^MDA (\\d+)/(\\d+)$" + librenms_class: "mdaModule" + netbox_bay_name: "\\1/\\2" + is_regex: true + description: "Nokia MDA N/M → N/M (matches {module}/M on IOM)" +- librenms_name: "^XIOM (\\d+)/x(\\d+)$" + librenms_class: "xioModule" + netbox_bay_name: "\\1/x\\2" + is_regex: true + description: "Nokia XIOM N/xM → N/xM (matches {module}/xM on IOM)" +- librenms_name: "^MDA (\\d+)/x(\\d+)/(\\d+)$" + librenms_class: "mdaModule" + netbox_bay_name: "x\\2/\\3" + is_regex: true + description: "Nokia MDA in XIOM N/xP/Q → xP/Q (matches {module}/Q on XIOM)" + +# Nokia transceiver connector bays +# LibreNMS ifName "1/1/c1" (slot/mda/connector) → NetBox bay "1/c1" +# ({module} on MDA resolves to position, stripping the slot prefix) +- librenms_name: "(\\d+)/(\\d+)/(c\\d+)" + librenms_class: "port" + netbox_bay_name: "\\2/\\3" + is_regex: true + description: "Nokia transceiver slot/mda/cN → mda-pos/cN" +# LibreNMS ifName "2/x1/1/c2" (slot/xiom/mda/connector) → NetBox bay "1/c2" +- librenms_name: "(\\d+)/x(\\d+)/(\\d+)/(c\\d+)" + librenms_class: "port" + netbox_bay_name: "\\3/\\4" + is_regex: true + description: "Nokia XIOM transceiver slot/xiom/mda/cN → mda-pos/cN" + +# Juniper MX transceiver bays +# LibreNMS entPhysicalDescr format: "SFP+-10G-SR @ {fpc}/{pic}/{port}" +# NetBox MPC-3D-16XGE-SFPP bay format: "Transceiver {pic}/{port}" +- librenms_name: "[^@]+ @ \\d+/(\\d+)/(\\d+)" + librenms_class: "port" + netbox_bay_name: "Transceiver \\1/\\2" + is_regex: true + description: "Juniper MX SFP+ @ fpc/pic/port → Transceiver pic/port" + +# ─── Exact Match Entries ───────────────────────────────────────────────────── +# These are for special cases where names don't follow a regex pattern. + +# Nokia CPM slots +- librenms_name: "Slot A" + librenms_class: "cpmModule" + netbox_bay_name: "Slot A" + description: "Nokia CPM slot A" +- librenms_name: "Slot B" + librenms_class: "cpmModule" + netbox_bay_name: "Slot B" + description: "Nokia CPM slot B" +- librenms_name: "SR-7s 2 CPM mini" + librenms_class: "cpmCarrier" + netbox_bay_name: "CMA" + description: "Nokia CMA2-7s CPM carrier bracket" + +# Juniper fixed-form devices +- librenms_name: "PSM 0" + librenms_class: "powerSupply" + netbox_bay_name: "PSU 0" + description: "Juniper PSU slot 0" +- librenms_name: "PSM 1" + librenms_class: "powerSupply" + netbox_bay_name: "PSU 1" + description: "Juniper PSU slot 1" + +# Juniper chassis devices (PTX10008 etc.): PSM → PEM +# Regex runs after exact matches, so PSM 0/1 → PSU 0/1 above takes priority for ACX +- librenms_name: "^PSM (\\d+)$" + librenms_class: "powerSupply" + netbox_bay_name: "PEM \\1" + is_regex: true + description: "Juniper chassis PSM N → PEM N" + +# Juniper FPC container: "FPC: @ N/*/*" → FPC N +- librenms_name: "^FPC: .+ @ (\\d+)/\\*/\\*$" + librenms_class: "container" + netbox_bay_name: "FPC \\1" + is_regex: true + description: "Juniper FPC container description → FPC N" + +# Juniper transceivers: " @ slot/pic/port" description → Transceiver slot/pic/port +- librenms_name: "^.+ @ (\\d+/\\d+/\\d+)$" + librenms_class: "port" + netbox_bay_name: "Transceiver \\1" + is_regex: true + description: "Juniper transceiver description → Transceiver slot/pic/port" + +# Juniper fan trays: "Fan Tray N" → "Fan N" (ACX7100, etc.) +# Runs after exact match, so "Fan Tray 0" → "Fan Tray" (ACX7024) still works +- librenms_name: "^Fan Tray (\\d+)$" + librenms_class: "fan" + netbox_bay_name: "Fan \\1" + is_regex: true + description: "Juniper Fan Tray N → Fan N (ACX7100 etc.)" + +# Juniper MX304: PEM → PSU (MX304 bays are named PSU, not PEM) +- librenms_name: "PEM 0" + librenms_class: "powerSupply" + netbox_bay_name: "PSU 0" + description: "Juniper MX304 PEM 0 → PSU 0" +- librenms_name: "PEM 1" + librenms_class: "powerSupply" + netbox_bay_name: "PSU 1" + description: "Juniper MX304 PEM 1 → PSU 1" + +- librenms_name: "Fan Tray 0" + librenms_class: "fan" + netbox_bay_name: "Fan Tray" + description: "Juniper single fan tray (ACX7024)" + +# Juniper PTX10008: SIB → CB (Switch Interface Board → Component Board slot) +- librenms_name: "SIB 0" + librenms_class: "container" + netbox_bay_name: "CB 0" + description: "Juniper PTX10008 SIB 0 → CB 0" +- librenms_name: "SIB 1" + librenms_class: "container" + netbox_bay_name: "CB 1" + description: "Juniper PTX10008 SIB 1 → CB 1" +- librenms_name: "SIB 2" + librenms_class: "container" + netbox_bay_name: "CB 2" + description: "Juniper PTX10008 SIB 2 → CB 2" +- librenms_name: "SIB 3" + librenms_class: "container" + netbox_bay_name: "CB 3" + description: "Juniper PTX10008 SIB 3 → CB 3" +- librenms_name: "SIB 4" + librenms_class: "container" + netbox_bay_name: "CB 4" + description: "Juniper PTX10008 SIB 4 → CB 4" +- librenms_name: "SIB 5" + librenms_class: "container" + netbox_bay_name: "CB 5" + description: "Juniper PTX10008 SIB 5 → CB 5" + +# Arcos power supplies +- librenms_name: "psu0" + librenms_class: "powerSupply" + netbox_bay_name: "PSU 0" + description: "Arcos PSU slot 0" +- librenms_name: "psu1" + librenms_class: "powerSupply" + netbox_bay_name: "PSU 1" + description: "Arcos PSU slot 1" diff --git a/contrib/module_type_mappings.yaml b/contrib/module_type_mappings.yaml new file mode 100644 index 0000000000..e70d726f1b --- /dev/null +++ b/contrib/module_type_mappings.yaml @@ -0,0 +1,332 @@ +# Module Type Mappings +# +# Maps LibreNMS inventory model names (entPhysicalModelName) to NetBox module types. +# Import via: LibreNMS Plugin > Module Type Mappings > Import +# +# Fields: +# librenms_model — Model name from LibreNMS SNMP inventory +# netbox_module_type — NetBox ModuleType (matched by model name or ID) +# description — Optional note +# +# These mappings are checked FIRST. If no mapping exists, the plugin falls back +# to exact model name and part_number matching against NetBox module types. + +# ─── Cisco Catalyst 4900M ──────────────────────────────────────────────────── + +- librenms_model: "WS-X4908-10GE" + netbox_module_type: "WS-X4908-10GE" + description: "Cisco 8-port 10G X2 line card" + +- librenms_model: "WS-X4992" + netbox_module_type: "WS-X4992" + description: "Cisco 48-port 10/100/1000 line card" + +- librenms_model: "PWR-C49M-1000AC" + netbox_module_type: "PWR-C49M-1000AC" + description: "Cisco 1000W AC power supply" + +- librenms_model: "CVR-X2-SFP" + netbox_module_type: "CVR-X2-SFP" + description: "Cisco X2-to-SFP converter" + +# ─── Juniper Backplane ─────────────────────────────────────────────────────── + +- librenms_model: "710-017414" + netbox_module_type: "MX480-CHASSIS-BP" + description: "Juniper MX480 backplane (matched by part number)" + +- librenms_model: "CHAS-BP-MX480-S" + netbox_module_type: "MX480-CHASSIS-BP" + description: "Juniper MX480 backplane (matched by name)" + +# ─── Juniper FPC / Line Card Mappings ──────────────────────────────────────── +# Juniper FPCs use 750-xxxxxx part numbers as entPhysicalModelName. + +- librenms_model: "750-018124" + netbox_module_type: "DPCE-R-4XGE-XFP" + description: "Juniper DPCE 4-port 10G XFP DPC" + +- librenms_model: "750-022765" + netbox_module_type: "DPCE-R-20GE-2XGE" + description: "Juniper DPCE 20x1G + 2x10G combo DPC" + +- librenms_model: "750-028467" + netbox_module_type: "MPC-3D-16XGE-SFPP" + description: "Juniper MPC 16-port 10G SFP+" + +- librenms_model: "750-056519" + netbox_module_type: "MPC7E-MRATE" + description: "Juniper MPC7E 12-port QSFP+/QSFP28 multirate" + +- librenms_model: "750-062581" + netbox_module_type: "MPC-3D-16XGE-SFPP" + description: "Juniper MPC 16-port 10G SFP+ (variant PN)" + +# ─── Juniper Power Supply Mappings ─────────────────────────────────────────── + +- librenms_model: "740-029970" + netbox_module_type: "PWR-MX480-2520-AC" + description: "Juniper MX480 2520W AC PSU" + +- librenms_model: "740-063046" + netbox_module_type: "PWR-MX480-2520-AC" + description: "Juniper MX480 2520W AC PSU (variant PN)" + +- librenms_model: "740-027760" + netbox_module_type: "PWR-MX960-4100-AC" + description: "Juniper MX960 4100W AC PSU" + +- librenms_model: "740-110419" + netbox_module_type: "JNP-PWR2200-AC" + description: "Juniper MX304 2200W AC PSU" + +# Removed: JPSU-1600W-1UACAFO — exact model match, no mapping needed + +# ─── Juniper Fan Tray Mappings ─────────────────────────────────────────────── + +- librenms_model: "740-031521" + netbox_module_type: "FFANTRAY-MX960-HC" + description: "Juniper MX960 high-capacity fan tray" + +- librenms_model: "760-126744" + netbox_module_type: "JNP-FAN-2RU" + description: "Juniper MX304 2RU fan tray" + +# Removed: JNP7100-FAN1RU-AO — exact model match, no mapping needed + +# ─── Nokia 7750 SR-7s Module Mappings ──────────────────────────────────────── +# Nokia 3HE part numbers are handled by NormalizationRule: +# 1. Strip extra text (e.g. "3HE10550AARA01 NOK IPU3BFUEAA" → "3HE10550AARA01") +# 2. Strip revision suffix (e.g. "3HE10550AARA01" → "3HE10550AA") +# The normalized value matches the part_number field on NetBox ModuleTypes. +# No explicit Nokia mappings are needed. + +# ─── Transceiver Mappings: Juniper Part Numbers ───────────────────────────── +# Juniper-qualified optics use 740-xxxxxx part numbers regardless of OEM vendor. + +- librenms_model: "740-013111" + netbox_module_type: "SFP-1G-T" + description: "Juniper SFP 1000BASE-T copper" + +- librenms_model: "740-021308" + netbox_module_type: "SFP-10G-SR" + description: "Juniper SFP+ 10G-SR" + +- librenms_model: "740-031850" + netbox_module_type: "SFP-1G-LX" + description: "Juniper SFP 1000BASE-LX 10km" + +- librenms_model: "740-031980" + netbox_module_type: "SFP-10G-SR" + description: "Juniper SFP+ 10G-SR" + +- librenms_model: "740-031981" + netbox_module_type: "SFP-10G-LR" + description: "Juniper SFP+ 10G-LR" + +- librenms_model: "740-047682" + netbox_module_type: "CFP-100G-LR4" + description: "Juniper CFP 100G-LR4" + +- librenms_model: "740-054050" + netbox_module_type: "QSFP-4X10G-LR" + description: "Juniper QSFP+ 4x10G-LR" + +- librenms_model: "740-054053" + netbox_module_type: "QSFP-4X10G-SR" + description: "Juniper QSFP+ 4x10G-SR" + +- librenms_model: "740-058732" + netbox_module_type: "QSFP-100G-LR4" + description: "Juniper QSFP28 100G-LR4" + +- librenms_model: "740-061405" + netbox_module_type: "QSFP-100G-SR4" + description: "Juniper QSFP28 100G-SR4" + +- librenms_model: "740-061409" + netbox_module_type: "QSFP-100G-LR4" + description: "Juniper QSFP28 100G-LR4" + +- librenms_model: "740-079871" + netbox_module_type: "QSFP28-DD-2X100G-LR4" + description: "Juniper QSFP-DD 2x100G-LR4" + +- librenms_model: "740-082823" + netbox_module_type: "QSFP-DD-400G-LR8" + description: "Juniper QSFP-DD 400G-LR8" + +- librenms_model: "740-085349" + netbox_module_type: "QSFP-DD-400G-FR4" + description: "Juniper QSFP-DD 400G-FR4" + +- librenms_model: "740-085351" + netbox_module_type: "QSFP-DD-400G-DR4" + description: "Juniper QSFP-DD 400G-DR4" + +- librenms_model: "740-096176" + netbox_module_type: "QSFP-DD-400G-LR4" + description: "Juniper QSFP-DD 400G-LR4 (10km variant)" + +- librenms_model: "740-131169" + netbox_module_type: "QSFP-DD-400G-ZR-M" + description: "Juniper QSFP-DD 400G-ZR-M" + +- librenms_model: "740-151745" + netbox_module_type: "QSFP-DD-400G-ZR-M-HP" + description: "Juniper QSFP-DD 400G-ZR-M high-power" + +- librenms_model: "740-172665" + netbox_module_type: "QSFP-100G-ZR" + description: "Juniper QSFP28 100G-ZR" + +# ─── Transceiver Mappings: Finisar / II-VI / Coherent ──────────────────────── +# These are BASE part numbers (after normalization strips customer suffixes). +# See contrib/normalization_rules.yaml for the Finisar suffix-stripping rule. + +- librenms_model: "FTLC1154RDPL" + netbox_module_type: "QSFP-100G-LR4" + description: "Finisar QSFP28 100G-LR4" + +- librenms_model: "FTLC1151RDPL" + netbox_module_type: "QSFP-100G-LR4" + description: "Finisar QSFP28 100G-LR4 (variant)" + +- librenms_model: "FTLX1474D3BCL" + netbox_module_type: "SFP-10G-LR" + description: "Finisar SFP+ 10G-LR" + +- librenms_model: "FTCD3323R1PCL" + netbox_module_type: "QSFP-DD-400G-ZR-M" + description: "Finisar/II-VI QSFP-DD 400G-ZR-M coherent" + +- librenms_model: "FTLC9152RGPL" + netbox_module_type: "QSFP-100G-SWDM4" + description: "Finisar QSFP28 100G-SWDM4" + +# ─── Transceiver Mappings: Cisco / Cisco-branded OEM ───────────────────────── + +- librenms_model: "X2-10GB-LR" + netbox_module_type: "X2-10GB-LR" + description: "Cisco X2 10G-LR" + +- librenms_model: "X2-10GB-SR" + netbox_module_type: "X2-10GB-SR" + description: "Cisco X2 10G-SR" + +- librenms_model: "GLC-T" + netbox_module_type: "SFP-1G-T" + description: "Cisco SFP 1000BASE-T copper" + +- librenms_model: "GLC-TE" + netbox_module_type: "SFP-1G-T" + description: "Cisco SFP 1000BASE-T copper (extended temp)" + +- librenms_model: "SPP5200LR-C5" + netbox_module_type: "SFP-10G-LR" + description: "Cisco-branded Sumitomo SFP+ 10G-LR" + +- librenms_model: "SPP5310LR-C5" + netbox_module_type: "SFP-10G-LR" + description: "Cisco-branded Sumitomo SFP+ 10G-LR" + +- librenms_model: "SFBR-709SMZ-CS1" + netbox_module_type: "SFP-10G-SR" + description: "Cisco-branded Avago/Broadcom SFP+ 10G-SR" + +- librenms_model: "DP04QSDD-HE0" + netbox_module_type: "QSFP-DD-400G-ZR+" + description: "Cisco/Acacia QSFP-DD 400G-ZR+ coherent" + +- librenms_model: "QDD-400G-ZRP-S" + netbox_module_type: "QSFP-DD-400G-ZR+" + description: "Cisco QSFP-DD 400G-ZR+" + +- librenms_model: "QDD-400G-ZR4-S" + netbox_module_type: "QSFP-DD-400G-ZR" + description: "Cisco QSFP-DD 400G-ZR" + +# ─── Transceiver Mappings: Ciena ───────────────────────────────────────────── + +- librenms_model: "180-3530-900" + netbox_module_type: "QSFP-DD-400G-ZR" + description: "Ciena WaveLogic 5 Nano QSFP-DD 400ZR" + +- librenms_model: "176-3360-900" + netbox_module_type: "QSFP-DD-400G-ZR-M" + description: "Ciena QSFP-DD 400G-ZR-M coherent" + +- librenms_model: "176-3530-901" + netbox_module_type: "QSFP-DD-400G-ZR" + description: "Ciena QSFP-DD 400G-ZR coherent" + +- librenms_model: "176-3590-900" + netbox_module_type: "QSFP-DD-400G-ZR-M" + description: "Ciena QSFP-DD 400G-ZR-M coherent" + +# ─── Transceiver Mappings: T1 Nexus ───────────────────────────────────────── + +- librenms_model: "T1-QDD-400G-LR4" + netbox_module_type: "QSFP-DD-400G-LR4" + description: "T1 Nexus QSFP-DD 400G-LR4" + +- librenms_model: "T1-QDD-400G-FR4" + netbox_module_type: "QSFP-DD-400G-FR4" + description: "T1 Nexus QSFP-DD 400G-FR4" + +- librenms_model: "T1-QSFP28-LR4" + netbox_module_type: "QSFP-100G-LR4" + description: "T1 Nexus QSFP28 100G-LR4" + +- librenms_model: "100G-LR4_A3" + netbox_module_type: "QSFP-100G-LR4" + description: "T1 Nexus QSFP28 100G-LR4 (rev A3)" + +# ─── Transceiver Mappings: Innolight ──────────────────────────────────────── + +- librenms_model: "T-DQ4CNT-NCN" + netbox_module_type: "QSFP-DD-400G-FR4" + description: "Innolight QSFP-DD 400G-FR4" + +# ─── Transceiver Mappings: FS.com ──────────────────────────────────────────── + +- librenms_model: "Q28-PC03" + netbox_module_type: "QSFP28-100G-CU3M" + description: "FS.com QSFP28 100G passive DAC 3m" + +# ─── Transceiver Mappings: ProLabs ─────────────────────────────────────────── + +- librenms_model: "Q28LR431-10-IN" + netbox_module_type: "QSFP-100G-LR4" + description: "ProLabs QSFP28 100G-LR4 10km" + +# ─── Transceiver Mappings: Arcos Fixed-Port Part Numbers ───────────────────── + +- librenms_model: "SP7041-TE" + netbox_module_type: "SFP-1G-T" + description: "SFP 1000BASE-T copper (Arcos platform)" + +# ─── Transceiver Mappings: LeGrand Innolight ───────────────────────────────── + +- librenms_model: "LGI-FTLC9152RGPL" + netbox_module_type: "QSFP-100G-SWDM4" + description: "LeGrand-branded Finisar QSFP28 100G-SWDM4" + +# ─── Transceiver Mappings: Additional Finisar Variants ────────────────────── +# Some transceivers have customer-code suffixes that normalization may not handle. +# Add direct mappings as fallback. + +- librenms_model: "FTLC1151RDPL-CN" + netbox_module_type: "QSFP-100G-LR4" + description: "Finisar QSFP28 100G-LR4 (CN customer code)" + +- librenms_model: "FTLC1154RDPL-A5" + netbox_module_type: "QSFP-100G-LR4" + description: "Finisar QSFP28 100G-LR4 (A5 customer code)" + +# ─── Unknown / Unidentified Part Numbers ───────────────────────────────────── +# These are mapped based on port context (QSFP28 100G slot) when vendor is unknown. + +- librenms_model: "1F3QAA" + netbox_module_type: "QSFP-100G-LR4" + description: "Unknown QSFP28 100G (mapped by port context)" diff --git a/contrib/normalization_rules.yaml b/contrib/normalization_rules.yaml new file mode 100644 index 0000000000..c3d2081bea --- /dev/null +++ b/contrib/normalization_rules.yaml @@ -0,0 +1,61 @@ +# Normalization Rules — Examples +# +# Regex-based string transformations applied before module type, device type, +# or module bay matching. Rules run in priority order (lower first); each +# rule's output feeds the next. +# +# Import via: LibreNMS → Normalization Rules → Import → YAML +# +# Fields: +# scope — module_type, device_type, or module_bay +# manufacturer — Optional manufacturer name (must exist in NetBox). +# When set, the rule only fires for that manufacturer. +# match_pattern — Python regex (re.sub pattern) +# replacement — Replacement string (supports \1, \2 back-references) +# priority — Lower values run first (default 100) +# description — Optional note + +# ── Nokia revision suffix stripping ────────────────────────────────────────── +# Nokia ENTITY-MIB reports module/transceiver models with 4-char revision +# suffixes (e.g. 3HE16474AARA01). NetBox module types use the base part +# number (3HE16474AA). This rule strips the suffix before matching. +# +# Captures the 10-char base (3HE + 5 alnum + 2 quality-tier letters), +# discards the 2-letter revision code + 2-digit build number. +- scope: module_type + manufacturer: Nokia + match_pattern: "^(3HE\\w{5}[A-Z]{2})[A-Z]{2}\\d{2}$" + replacement: "\\1" + priority: 100 + description: "Strip Nokia revision suffixes (e.g. RA01, RB01, RG01) from ENTITY-MIB model strings" + +# ── Finisar / II-VI / Coherent suffix stripping ───────────────────────────── +# Finisar part numbers have customer-specific suffixes after a hyphen: +# FTLC1154RDPL-A5 (original Finisar) +# FTLC1154RDPL-C (Prolabs compatible) +# FTLX1474D3BCL-C1 (Cisco-coded Finisar) +# This rule strips everything after the last hyphen for FT... models. +- scope: module_type + match_pattern: "^(FT[A-Z0-9]+)-[A-Z0-9]+$" + replacement: "\\1" + priority: 100 + description: "Strip Finisar/II-VI customer suffixes (-A5, -C, -CN, -C1, etc.)" + +# ── Prolabs LGI- prefix stripping ─────────────────────────────────────────── +# Prolabs-compatible optics sometimes prepend LGI- to the OEM part number: +# LGI-FTLC9152RGPL → FTLC9152RGPL +- scope: module_type + match_pattern: "^LGI-(.+)$" + replacement: "\\1" + priority: 50 + description: "Strip Prolabs LGI- prefix from OEM part numbers" + +# ── Nokia transceiver model field cleanup ──────────────────────────────────── +# Nokia transceiver API sometimes returns model strings with trailing vendor +# info: "3HE10550AARA01 NOK IPU3BFUEAA" — extract just the part number. +- scope: module_type + manufacturer: Nokia + match_pattern: "^(3HE\\w+)\\s+.*$" + replacement: "\\1" + priority: 50 + description: "Extract Nokia part number from transceiver model field (strip trailing vendor/oui info)" diff --git a/netbox_librenms_plugin/__init__.py b/netbox_librenms_plugin/__init__.py index 299ceadd3e..6b60735762 100644 --- a/netbox_librenms_plugin/__init__.py +++ b/netbox_librenms_plugin/__init__.py @@ -70,18 +70,19 @@ def _validate_legacy_config(self, plugin_config): def _ensure_librenms_id_custom_field(sender, **kwargs): """ - Auto-create the 'librenms_id' custom field if it doesn't exist. + Auto-create (or migrate) the 'librenms_id' custom field. Runs after migrations via post_migrate signal to ensure tables exist. Uses dispatch_uid to avoid duplicate connections. + + librenms_id stores a per-server JSON mapping {"server_key": device_id}. + Legacy installations may have this field typed as 'integer'; we upgrade it + to 'json' automatically so the UI and API accept the dict format. """ # Only run once per migrate invocation (post_migrate fires per-app). - # The _executed flag is intentionally never reset: migrations are expected to - # run in short-lived CLI processes (manage.py migrate) where the flag is - # naturally cleared on exit. Long-running processes (e.g. gunicorn workers) - # should not rely on this handler re-executing after startup. if getattr(_ensure_librenms_id_custom_field, "_executed", False): return - _ensure_librenms_id_custom_field._executed = True # not reset; see comment above + + import logging try: from django.contrib.contenttypes.models import ContentType @@ -101,6 +102,15 @@ def _ensure_librenms_id_custom_field(sender, **kwargs): }, ) + # Migrate legacy integer-typed field to JSON so the multi-server + # dict format {"server_key": device_id} is accepted by the UI/API. + if not created and cf.type == "integer": + cf.type = "json" + cf.save(update_fields=["type"]) + logging.getLogger("netbox_librenms_plugin").info( + "Migrated 'librenms_id' custom field type from integer to json" + ) + # Ensure the field is assigned to the required object types from dcim.models import Device, Interface from virtualization.models import VirtualMachine, VMInterface @@ -114,16 +124,15 @@ def _ensure_librenms_id_custom_field(sender, **kwargs): cf.object_types.add(ct) if created: - import logging - logging.getLogger("netbox_librenms_plugin").info( "Auto-created 'librenms_id' custom field for Device, VirtualMachine, Interface, VMInterface" ) + + # Only mark as executed after successful completion to allow retry on failure. + _ensure_librenms_id_custom_field._executed = True except Exception as e: # Don't break startup if custom field creation fails (e.g., during initial migration), # but log the error so it's not silently swallowed. - import logging - logging.getLogger("netbox_librenms_plugin").exception("Failed to auto-create 'librenms_id' custom field: %s", e) diff --git a/netbox_librenms_plugin/api/serializers.py b/netbox_librenms_plugin/api/serializers.py index 6bcd0aef20..da41fcc272 100644 --- a/netbox_librenms_plugin/api/serializers.py +++ b/netbox_librenms_plugin/api/serializers.py @@ -1,6 +1,13 @@ from netbox.api.serializers import NetBoxModelSerializer -from netbox_librenms_plugin.models import InterfaceTypeMapping +from netbox_librenms_plugin.models import ( + DeviceTypeMapping, + InterfaceTypeMapping, + InventoryIgnoreRule, + ModuleBayMapping, + ModuleTypeMapping, + NormalizationRule, +) class InterfaceTypeMappingSerializer(NetBoxModelSerializer): @@ -11,3 +18,70 @@ class Meta: model = InterfaceTypeMapping fields = ["id", "librenms_type", "librenms_speed", "netbox_type", "description"] + + +class DeviceTypeMappingSerializer(NetBoxModelSerializer): + """Serialize DeviceTypeMapping model for REST API.""" + + class Meta: + """Meta options for DeviceTypeMappingSerializer.""" + + model = DeviceTypeMapping + fields = ["id", "librenms_hardware", "netbox_device_type", "description"] + + +class ModuleTypeMappingSerializer(NetBoxModelSerializer): + """Serialize ModuleTypeMapping model for REST API.""" + + class Meta: + """Meta options for ModuleTypeMappingSerializer.""" + + model = ModuleTypeMapping + fields = ["id", "librenms_model", "netbox_module_type", "description"] + + +class ModuleBayMappingSerializer(NetBoxModelSerializer): + """Serialize ModuleBayMapping model for REST API.""" + + class Meta: + """Meta options for ModuleBayMappingSerializer.""" + + model = ModuleBayMapping + fields = ["id", "librenms_name", "librenms_class", "netbox_bay_name", "is_regex", "description"] + + +class NormalizationRuleSerializer(NetBoxModelSerializer): + """Serialize NormalizationRule model for REST API.""" + + class Meta: + """Meta options for NormalizationRuleSerializer.""" + + model = NormalizationRule + fields = [ + "id", + "scope", + "manufacturer", + "match_pattern", + "replacement", + "priority", + "description", + ] + + +class InventoryIgnoreRuleSerializer(NetBoxModelSerializer): + """Serialize InventoryIgnoreRule model for REST API.""" + + class Meta: + """Meta options for InventoryIgnoreRuleSerializer.""" + + model = InventoryIgnoreRule + fields = [ + "id", + "name", + "match_type", + "pattern", + "action", + "require_serial_match_parent", + "enabled", + "description", + ] diff --git a/netbox_librenms_plugin/api/urls.py b/netbox_librenms_plugin/api/urls.py index 230aa078d0..4963035043 100644 --- a/netbox_librenms_plugin/api/urls.py +++ b/netbox_librenms_plugin/api/urls.py @@ -7,6 +7,11 @@ router = NetBoxRouter() router.register("interface-type-mappings", views.InterfaceTypeMappingViewSet) +router.register("device-type-mappings", views.DeviceTypeMappingViewSet) +router.register("module-type-mappings", views.ModuleTypeMappingViewSet) +router.register("module-bay-mappings", views.ModuleBayMappingViewSet) +router.register("normalization-rules", views.NormalizationRuleViewSet) +router.register("inventory-ignore-rules", views.InventoryIgnoreRuleViewSet) urlpatterns = [ path("jobs//sync-status/", views.sync_job_status, name="sync_job_status"), diff --git a/netbox_librenms_plugin/api/views.py b/netbox_librenms_plugin/api/views.py index 768c67f5fe..19b5bb975f 100644 --- a/netbox_librenms_plugin/api/views.py +++ b/netbox_librenms_plugin/api/views.py @@ -11,9 +11,23 @@ from rq.job import Job as RQJob from netbox_librenms_plugin.constants import PERM_CHANGE_PLUGIN, PERM_VIEW_PLUGIN -from netbox_librenms_plugin.models import InterfaceTypeMapping - -from .serializers import InterfaceTypeMappingSerializer +from netbox_librenms_plugin.models import ( + DeviceTypeMapping, + InterfaceTypeMapping, + InventoryIgnoreRule, + ModuleBayMapping, + ModuleTypeMapping, + NormalizationRule, +) + +from .serializers import ( + DeviceTypeMappingSerializer, + InterfaceTypeMappingSerializer, + InventoryIgnoreRuleSerializer, + ModuleBayMappingSerializer, + ModuleTypeMappingSerializer, + NormalizationRuleSerializer, +) logger = logging.getLogger(__name__) @@ -22,8 +36,8 @@ class LibreNMSPluginPermission(BasePermission): """ Permission class for LibreNMS plugin API endpoints. - - GET requests require view_librenmssettings - - All other requests require change_librenmssettings + - Safe requests (GET, HEAD, OPTIONS) require netbox_librenms_plugin.view_librenmssettings + - All other requests require netbox_librenms_plugin.change_librenmssettings """ def has_permission(self, request, view): @@ -41,6 +55,51 @@ class InterfaceTypeMappingViewSet(NetBoxModelViewSet): serializer_class = InterfaceTypeMappingSerializer +class DeviceTypeMappingViewSet(NetBoxModelViewSet): + """API viewset for DeviceTypeMapping CRUD operations.""" + + permission_classes = [LibreNMSPluginPermission] + + queryset = DeviceTypeMapping.objects.all() + serializer_class = DeviceTypeMappingSerializer + + +class ModuleTypeMappingViewSet(NetBoxModelViewSet): + """API viewset for ModuleTypeMapping CRUD operations.""" + + permission_classes = [LibreNMSPluginPermission] + + queryset = ModuleTypeMapping.objects.all() + serializer_class = ModuleTypeMappingSerializer + + +class ModuleBayMappingViewSet(NetBoxModelViewSet): + """API viewset for ModuleBayMapping CRUD operations.""" + + permission_classes = [LibreNMSPluginPermission] + + queryset = ModuleBayMapping.objects.all() + serializer_class = ModuleBayMappingSerializer + + +class NormalizationRuleViewSet(NetBoxModelViewSet): + """API viewset for NormalizationRule CRUD operations.""" + + permission_classes = [LibreNMSPluginPermission] + + queryset = NormalizationRule.objects.all() + serializer_class = NormalizationRuleSerializer + + +class InventoryIgnoreRuleViewSet(NetBoxModelViewSet): + """API viewset for InventoryIgnoreRule CRUD operations.""" + + permission_classes = [LibreNMSPluginPermission] + + queryset = InventoryIgnoreRule.objects.all() + serializer_class = InventoryIgnoreRuleSerializer + + @api_view(["POST"]) @permission_classes([LibreNMSPluginPermission]) def sync_job_status(request, job_pk): diff --git a/netbox_librenms_plugin/filters.py b/netbox_librenms_plugin/filters.py index 9ec162a64c..bc44e8eba2 100644 --- a/netbox_librenms_plugin/filters.py +++ b/netbox_librenms_plugin/filters.py @@ -1,6 +1,13 @@ import django_filters -from .models import InterfaceTypeMapping +from .models import ( + DeviceTypeMapping, + InterfaceTypeMapping, + InventoryIgnoreRule, + ModuleBayMapping, + ModuleTypeMapping, + NormalizationRule, +) class InterfaceTypeMappingFilterSet(django_filters.FilterSet): @@ -11,3 +18,53 @@ class Meta: model = InterfaceTypeMapping fields = ["librenms_type", "librenms_speed", "netbox_type", "description"] + + +class DeviceTypeMappingFilterSet(django_filters.FilterSet): + """Filter set for DeviceTypeMapping model.""" + + class Meta: + """Meta options for DeviceTypeMappingFilterSet.""" + + model = DeviceTypeMapping + fields = ["librenms_hardware", "description"] + + +class ModuleTypeMappingFilterSet(django_filters.FilterSet): + """Filter set for ModuleTypeMapping model.""" + + class Meta: + """Meta options for ModuleTypeMappingFilterSet.""" + + model = ModuleTypeMapping + fields = ["librenms_model", "description"] + + +class ModuleBayMappingFilterSet(django_filters.FilterSet): + """Filter set for ModuleBayMapping model.""" + + class Meta: + """Meta options for ModuleBayMappingFilterSet.""" + + model = ModuleBayMapping + fields = ["librenms_name", "librenms_class", "netbox_bay_name", "is_regex"] + + +class NormalizationRuleFilterSet(django_filters.FilterSet): + """Filter set for NormalizationRule model.""" + + class Meta: + """Meta options for NormalizationRuleFilterSet.""" + + model = NormalizationRule + fields = ["scope", "manufacturer"] + + +class InventoryIgnoreRuleFilterSet(django_filters.FilterSet): + """Filter set for InventoryIgnoreRule model.""" + + class Meta: + """Meta options for InventoryIgnoreRuleFilterSet.""" + + model = InventoryIgnoreRule + fields = ["match_type", "enabled"] diff --git a/netbox_librenms_plugin/forms.py b/netbox_librenms_plugin/forms.py index da1417b715..5559bd4f8a 100644 --- a/netbox_librenms_plugin/forms.py +++ b/netbox_librenms_plugin/forms.py @@ -2,7 +2,7 @@ import logging from dcim.choices import InterfaceTypeChoices -from dcim.models import Device, DeviceRole, DeviceType, Location, Rack, Site +from dcim.models import Device, DeviceRole, DeviceType, Location, Manufacturer, ModuleType, Rack, Site from django import forms from django.db.models import Case, IntegerField, Value, When from django.http import QueryDict @@ -13,10 +13,23 @@ NetBoxModelImportForm, ) from netbox.plugins import get_plugin_config -from utilities.forms.fields import CSVChoiceField, DynamicModelMultipleChoiceField +from utilities.forms.fields import ( + CSVChoiceField, + CSVModelChoiceField, + DynamicModelChoiceField, + DynamicModelMultipleChoiceField, +) from virtualization.models import Cluster, VirtualMachine -from .models import InterfaceTypeMapping, LibreNMSSettings +from .models import ( + DeviceTypeMapping, + InterfaceTypeMapping, + InventoryIgnoreRule, + LibreNMSSettings, + ModuleBayMapping, + ModuleTypeMapping, + NormalizationRule, +) logger = logging.getLogger(__name__) @@ -52,11 +65,23 @@ def _get_librenms_poller_group_choices(): """ Helper function to get poller group choices from LibreNMS API. Shared between AddToLIbreSNMPV1V2 and AddToLIbreSNMPV3 forms. + Results are cached to avoid repeated API calls on every form instantiation. """ + from django.core.cache import cache + from .librenms_api import LibreNMSAPI choices = [("0", "Default (0)")] + try: + api = LibreNMSAPI() + cache_key = f"librenms_poller_group_choices_{api.server_key}" + except Exception: + cache_key = "librenms_poller_group_choices" + cached_choices = cache.get(cache_key) + if cached_choices: + return cached_choices + try: api = LibreNMSAPI() success, poller_groups = api.get_poller_groups() @@ -73,6 +98,8 @@ def _get_librenms_poller_group_choices(): else: label = f"{group_name} ({group_id})" choices.append((group_id, label)) + + cache.set(cache_key, choices, timeout=api.cache_timeout) except Exception: logger.exception("Failed to fetch LibreNMS poller groups; using default choices") @@ -261,6 +288,256 @@ class InterfaceTypeMappingFilterForm(NetBoxModelFilterSetForm): model = InterfaceTypeMapping +class DeviceTypeMappingForm(NetBoxModelForm): + """Form for creating and editing device type mappings between LibreNMS and NetBox.""" + + netbox_device_type = forms.ModelChoiceField( + queryset=DeviceType.objects.all(), + label="NetBox Device Type", + widget=forms.Select(attrs={"class": "form-select"}), + ) + + class Meta: + """Meta options for DeviceTypeMappingForm.""" + + model = DeviceTypeMapping + fields = ["librenms_hardware", "netbox_device_type", "description"] + + +class DeviceTypeMappingImportForm(NetBoxModelImportForm): + """Form for bulk importing device type mappings.""" + + manufacturer = CSVModelChoiceField( + queryset=Manufacturer.objects.all(), + to_field_name="name", + required=False, + help_text="Manufacturer name — required when the model name is not unique across manufacturers", + ) + netbox_device_type = CSVModelChoiceField( + queryset=DeviceType.objects.all(), + to_field_name="model", + help_text="NetBox device type model name", + ) + + class Meta: + """Meta options for DeviceTypeMappingImportForm.""" + + model = DeviceTypeMapping + fields = ["librenms_hardware", "manufacturer", "netbox_device_type", "description"] + + def __init__(self, data=None, *args, **kwargs): + super().__init__(data, *args, **kwargs) + if data: + mfr_val = data.get("manufacturer") + if mfr_val: + mfr_field = self.fields["manufacturer"] + params = {f"manufacturer__{mfr_field.to_field_name}": mfr_val} + self.fields["netbox_device_type"].queryset = DeviceType.objects.filter(**params) + + +class DeviceTypeMappingFilterForm(NetBoxModelFilterSetForm): + """Form for filtering device type mappings.""" + + librenms_hardware = forms.CharField(required=False, label="LibreNMS Hardware") + description = forms.CharField( + required=False, + label="Description", + help_text="Filter by description (partial match)", + ) + + model = DeviceTypeMapping + + +class ModuleTypeMappingForm(NetBoxModelForm): + """Form for creating and editing module type mappings between LibreNMS and NetBox.""" + + class Meta: + """Meta options for ModuleTypeMappingForm.""" + + model = ModuleTypeMapping + fields = ["librenms_model", "netbox_module_type", "description"] + + +class ModuleTypeMappingImportForm(NetBoxModelImportForm): + """Form for bulk importing module type mappings.""" + + manufacturer = CSVModelChoiceField( + queryset=Manufacturer.objects.all(), + to_field_name="name", + required=False, + help_text="Manufacturer name — required when the model name is not unique across manufacturers", + ) + netbox_module_type = CSVModelChoiceField( + queryset=ModuleType.objects.all(), + to_field_name="model", + help_text="NetBox module type model name", + ) + + class Meta: + """Meta options for ModuleTypeMappingImportForm.""" + + model = ModuleTypeMapping + fields = ["librenms_model", "manufacturer", "netbox_module_type", "description"] + + def __init__(self, data=None, *args, **kwargs): + super().__init__(data, *args, **kwargs) + if data: + mfr_val = data.get("manufacturer") + if mfr_val: + mfr_field = self.fields["manufacturer"] + params = {f"manufacturer__{mfr_field.to_field_name}": mfr_val} + self.fields["netbox_module_type"].queryset = ModuleType.objects.filter(**params) + + +class ModuleTypeMappingFilterForm(NetBoxModelFilterSetForm): + """Form for filtering module type mappings.""" + + librenms_model = forms.CharField(required=False, label="LibreNMS Model") + description = forms.CharField( + required=False, + label="Description", + help_text="Filter by description (partial match)", + ) + + model = ModuleTypeMapping + + +class ModuleBayMappingForm(NetBoxModelForm): + """Form for creating and editing module bay mappings between LibreNMS and NetBox.""" + + class Meta: + """Meta options for ModuleBayMappingForm.""" + + model = ModuleBayMapping + fields = ["librenms_name", "librenms_class", "netbox_bay_name", "is_regex", "description"] + + +class ModuleBayMappingImportForm(NetBoxModelImportForm): + """Form for bulk importing module bay mappings.""" + + class Meta: + """Meta options for ModuleBayMappingImportForm.""" + + model = ModuleBayMapping + fields = ["librenms_name", "librenms_class", "netbox_bay_name", "is_regex", "description"] + + +class ModuleBayMappingFilterForm(NetBoxModelFilterSetForm): + """Form for filtering module bay mappings.""" + + librenms_name = forms.CharField(required=False, label="LibreNMS Name") + librenms_class = forms.CharField(required=False, label="LibreNMS Class") + netbox_bay_name = forms.CharField(required=False, label="NetBox Bay Name") + is_regex = forms.NullBooleanField(required=False, label="Regex") + + model = ModuleBayMapping + + +class NormalizationRuleForm(NetBoxModelForm): + """Form for creating and editing normalization rules.""" + + manufacturer = DynamicModelChoiceField( + queryset=Manufacturer.objects.all(), + required=False, + help_text="Optional: scope this rule to a specific manufacturer", + ) + + class Meta: + """Meta options for NormalizationRuleForm.""" + + model = NormalizationRule + fields = ["scope", "manufacturer", "match_pattern", "replacement", "priority", "description"] + + +class NormalizationRuleImportForm(NetBoxModelImportForm): + """Form for bulk importing normalization rules.""" + + scope = CSVChoiceField( + choices=NormalizationRule.SCOPE_CHOICES, + help_text="Scope: module_type, device_type, or module_bay", + ) + manufacturer = CSVModelChoiceField( + queryset=Manufacturer.objects.all(), + to_field_name="name", + required=False, + help_text="Optional manufacturer name (must already exist in NetBox)", + ) + + class Meta: + """Meta options for NormalizationRuleImportForm.""" + + model = NormalizationRule + fields = ["scope", "manufacturer", "match_pattern", "replacement", "priority", "description"] + + +class NormalizationRuleFilterForm(NetBoxModelFilterSetForm): + """Form for filtering normalization rules.""" + + scope = forms.ChoiceField( + required=False, + choices=[("", "---------")] + NormalizationRule.SCOPE_CHOICES, + label="Scope", + ) + manufacturer_id = DynamicModelChoiceField( + queryset=Manufacturer.objects.all(), + required=False, + label="Manufacturer", + ) + + model = NormalizationRule + + +class InventoryIgnoreRuleForm(NetBoxModelForm): + """Form for creating and editing inventory ignore rules.""" + + class Meta: + """Meta options for InventoryIgnoreRuleForm.""" + + model = InventoryIgnoreRule + fields = ["name", "match_type", "pattern", "action", "require_serial_match_parent", "enabled", "description"] + + +class InventoryIgnoreRuleImportForm(NetBoxModelImportForm): + """Form for bulk importing inventory ignore rules.""" + + match_type = CSVChoiceField( + choices=InventoryIgnoreRule.MATCH_TYPE_CHOICES, + help_text="Match type: ends_with, starts_with, contains, regex, or serial_matches_device", + ) + action = CSVChoiceField( + choices=InventoryIgnoreRule.ACTION_CHOICES, + help_text="Action: skip (remove from table) or transparent (hide row, promote children)", + ) + + class Meta: + """Meta options for InventoryIgnoreRuleImportForm.""" + + model = InventoryIgnoreRule + fields = ["name", "match_type", "pattern", "action", "require_serial_match_parent", "enabled", "description"] + + +class InventoryIgnoreRuleFilterForm(NetBoxModelFilterSetForm): + """Form for filtering inventory ignore rules.""" + + match_type = forms.ChoiceField( + required=False, + choices=[("", "---------")] + InventoryIgnoreRule.MATCH_TYPE_CHOICES, + label="Match Type", + ) + action = forms.ChoiceField( + required=False, + choices=[("", "---------")] + InventoryIgnoreRule.ACTION_CHOICES, + label="Action", + ) + enabled = forms.NullBooleanField( + required=False, + widget=forms.Select(choices=[("", "---------"), ("true", "Yes"), ("false", "No")]), + label="Enabled", + ) + + model = InventoryIgnoreRule + + class AddToLIbreSNMPV1V2(forms.Form): """ Form for adding devices to LibreNMS using SNMPv1 or SNMPv2c authentication. diff --git a/netbox_librenms_plugin/import_utils/bulk_import.py b/netbox_librenms_plugin/import_utils/bulk_import.py index 380a5ab61f..e37fc4ac7e 100644 --- a/netbox_librenms_plugin/import_utils/bulk_import.py +++ b/netbox_librenms_plugin/import_utils/bulk_import.py @@ -52,7 +52,6 @@ def bulk_import_devices_shared( sync_options: dict = None, manual_mappings_per_device: dict = None, libre_devices_cache: dict = None, - vc_detection_enabled: bool = False, job=None, user=None, ) -> dict: @@ -70,8 +69,6 @@ def bulk_import_devices_shared( Example: {1179: {'device_role_id': 5}, 1180: {'device_role_id': 3}} libre_devices_cache: Optional dict mapping device_id to pre-fetched device data to avoid redundant API calls. Example: {123: {...device_data...}} - vc_detection_enabled: Whether to enable virtual chassis detection during import. - Should match the flag used during the filter/preview step for consistency. job: Optional JobRunner instance for progress logging and cancellation checks user: User performing the import (for permission checks). If job is provided, user is extracted from job.job.user if not explicitly passed. @@ -174,10 +171,10 @@ def bulk_import_devices_shared( validation = validate_device_for_import( libre_device, api=api, + include_vc_detection=(sync_options.get("vc_detection_enabled", True) if sync_options else True), use_sysname=use_sysname_opt, strip_domain=strip_domain_opt, server_key=api.server_key, - include_vc_detection=vc_detection_enabled, ) # Build manual mappings from validation + any provided overrides @@ -315,7 +312,6 @@ def bulk_import_devices( sync_options: dict = None, manual_mappings_per_device: dict = None, libre_devices_cache: dict = None, - vc_detection_enabled: bool = False, user=None, ) -> dict: """ @@ -332,7 +328,6 @@ def bulk_import_devices( Example: {1179: {'device_role_id': 5}, 1180: {'device_role_id': 3}} libre_devices_cache: Optional dict mapping device_id to pre-fetched device data to avoid redundant API calls. Example: {123: {...device_data...}} - vc_detection_enabled: Whether to enable virtual chassis detection during import. user: User performing the import (for permission checks) Returns: @@ -354,7 +349,6 @@ def bulk_import_devices( sync_options=sync_options, manual_mappings_per_device=manual_mappings_per_device, libre_devices_cache=libre_devices_cache, - vc_detection_enabled=vc_detection_enabled, job=None, # No job context for synchronous imports user=user, ) @@ -551,6 +545,23 @@ def process_device_filters( else: logger.info(f"Found {len(libre_devices)} devices") + # Check for early cancellation before the expensive VC prefetch + if job: + try: + from django_rq import get_queue + from rq.job import Job as RQJob + + queue = get_queue("default") + rq_job = RQJob.fetch(str(job.job.job_id), connection=queue.connection) + if rq_job.is_failed or rq_job.is_stopped: + job.logger.warning("Job was stopped before VC pre-fetch") + return _empty_return(return_cache_status) + except Exception: + job.job.refresh_from_db() + if job.job.status in (JobStatusChoices.STATUS_FAILED, JobStatusChoices.STATUS_ERRORED): + job.logger.warning("Job was stopped before VC pre-fetch") + return _empty_return(return_cache_status) + # Pre-warm VC cache if needed if vc_detection_enabled and libre_devices: device_ids = [d["device_id"] for d in libre_devices] diff --git a/netbox_librenms_plugin/import_utils/cache.py b/netbox_librenms_plugin/import_utils/cache.py index 65bd470a31..06f744d3a3 100644 --- a/netbox_librenms_plugin/import_utils/cache.py +++ b/netbox_librenms_plugin/import_utils/cache.py @@ -228,5 +228,6 @@ def get_import_search_cache_key(server_key: str, api_filters: dict, client_filte str: Cache key for the import search result. """ return ( - f"librenms_devices_import_{server_key}_{_build_filter_hash(api_filters)}_{_build_filter_hash(client_filters)}" + f"librenms_devices_import_{server_key}_" + f"{_build_filter_hash(api_filters)}_{_build_filter_hash(client_filters)}" ) diff --git a/netbox_librenms_plugin/import_utils/virtual_chassis.py b/netbox_librenms_plugin/import_utils/virtual_chassis.py index b4ef6f0676..566b1212ab 100644 --- a/netbox_librenms_plugin/import_utils/virtual_chassis.py +++ b/netbox_librenms_plugin/import_utils/virtual_chassis.py @@ -521,7 +521,6 @@ def create_virtual_chassis_with_members( ) members_created += 1 - # Validate member count # Validate member count — exclude master-slot entries with blank serials expected_members = len( [ diff --git a/netbox_librenms_plugin/import_utils/vm_operations.py b/netbox_librenms_plugin/import_utils/vm_operations.py index e1c43cc434..33fbca516d 100644 --- a/netbox_librenms_plugin/import_utils/vm_operations.py +++ b/netbox_librenms_plugin/import_utils/vm_operations.py @@ -28,8 +28,8 @@ def create_vm_from_librenms( Args: libre_device: Device data from LibreNMS validation: Validation result from validate_device_for_import with import_as_vm=True + server_key: LibreNMS server key used to store the librenms_id custom field (required) use_sysname: If True, prefer sysName; if False, use hostname - server_key: LibreNMS server key used to store the librenms_id custom field Returns: Created VirtualMachine instance diff --git a/netbox_librenms_plugin/jobs.py b/netbox_librenms_plugin/jobs.py index d8aa638c73..781deb28c2 100644 --- a/netbox_librenms_plugin/jobs.py +++ b/netbox_librenms_plugin/jobs.py @@ -166,7 +166,6 @@ def run( vm_imports, server_key=None, sync_options=None, - vc_detection_enabled=False, manual_mappings_per_device=None, libre_devices_cache=None, **kwargs, @@ -179,7 +178,6 @@ def run( vm_imports: Dict mapping device_id to cluster/role info for VM imports server_key: Optional LibreNMS server key for multi-server setups sync_options: Dict with sync_interfaces, sync_cables, sync_ips, use_sysname, strip_domain - vc_detection_enabled: Whether VC detection was enabled during the filter step. manual_mappings_per_device: Dict mapping device_id to manual_mappings dict libre_devices_cache: Optional dict mapping device_id to pre-fetched device data **kwargs: Additional job parameters @@ -214,7 +212,6 @@ def run( sync_options=sync_options, manual_mappings_per_device=manual_mappings_per_device, libre_devices_cache=libre_devices_cache, - vc_detection_enabled=vc_detection_enabled, job=self, # Pass job context for logging and cancellation user=self.job.user, # Pass user for permission checks ) diff --git a/netbox_librenms_plugin/librenms_api.py b/netbox_librenms_plugin/librenms_api.py index c4cefea76c..bbb84c01a7 100644 --- a/netbox_librenms_plugin/librenms_api.py +++ b/netbox_librenms_plugin/librenms_api.py @@ -712,6 +712,52 @@ def get_device_inventory(self, device_id): except (requests.exceptions.RequestException, ValueError) as e: return False, str(e) + def get_device_transceivers(self, device_id): + """ + Fetch all transceiver data for a device from LibreNMS. + + Route: /api/v0/devices/{device_id}/transceivers + + This is a separate data source from entity inventory. Some vendors + (e.g., Nokia/SROS) don't expose SFPs via ENTITY-MIB but do report + them through vendor-specific MIBs which LibreNMS surfaces here. + + Args: + device_id: LibreNMS device ID + + Returns: + tuple: (success: bool, data: list) + + Example transceiver item: + { + "port_id": 519, + "entity_physical_index": 1610899520, + "type": "CFP2/QSFP28", + "model": "3HE10550AARA01", + "serial": "X42AU0D", + "channels": 4, + "connector": "LC", + "wavelength": 1301, + ... + } + """ + try: + response = requests.get( + f"{self.librenms_url}/api/v0/devices/{device_id}/transceivers", + headers=self.headers, + timeout=DEFAULT_API_TIMEOUT, + verify=self.verify_ssl, + ) + response.raise_for_status() + + data = response.json() + transceivers = data.get("transceivers") if isinstance(data, dict) else None + if not isinstance(transceivers, list): + return False, f"Unexpected transceivers response format for device {device_id}" + return True, transceivers + except requests.exceptions.RequestException as e: + return False, str(e) + def get_poller_groups(self): """ Fetch all poller groups from LibreNMS. @@ -960,6 +1006,7 @@ def get_device_vlans(self, device_id: int) -> tuple[bool, list | str]: if isinstance(result, dict): return False, result.get("message") or "Unexpected response format" return False, "Unexpected response format" + except requests.exceptions.HTTPError as e: if e.response.status_code == 404: return False, "VLANs resource not found" @@ -1014,8 +1061,8 @@ def get_port_vlan_details(self, port_id: int) -> tuple[bool, dict | str]: if not isinstance(port_data[0], dict): return False, "Unexpected response format: invalid 'port' entry" return True, port_data[0] + return False, f"Unexpected HTTP status {response.status_code}" - return False, f"HTTP {response.status_code}" except requests.exceptions.HTTPError as e: if e.response.status_code == 404: return False, "Port not found in LibreNMS" diff --git a/netbox_librenms_plugin/migrations/0009_inventory_models.py b/netbox_librenms_plugin/migrations/0009_inventory_models.py new file mode 100644 index 0000000000..ce964ecced --- /dev/null +++ b/netbox_librenms_plugin/migrations/0009_inventory_models.py @@ -0,0 +1,269 @@ +""" +Add inventory/modules sync models. + +Squashed from 0009–0014 (never shipped): + 0009_add_devicetypemapping + 0010_add_moduletypemapping + 0011_modulebaymapping + 0012_add_is_regex_to_modulebaymapping + 0013_normalizationrule + 0014_inventoryignorerule +""" + +import django.db.models.deletion +import netbox.models.deletion +import taggit.managers +import utilities.json +from django.db import migrations, models + + +def _insert_default_rules(apps, schema_editor): + InventoryIgnoreRule = apps.get_model("netbox_librenms_plugin", "InventoryIgnoreRule") + InventoryIgnoreRule.objects.create( + name="Cisco IOS-XR IDPROM entries", + match_type="ends_with", + pattern="IDPROM", + action="skip", + require_serial_match_parent=True, + enabled=True, + description=( + "Cisco IOS-XR reports every hardware component's EEPROM chip as a child " + "ENTITY-MIB entity with the same model name and serial number as the parent " + "(e.g. 'Optics0/0/0/0-IDPROM', '0/FT0-FT IDPROM', 'Rack 0-Chassis IDPROM'). " + "These are not installable modules. This rule replicates the previous " + "hardcoded _is_idprom_entry() behaviour." + ), + ) + InventoryIgnoreRule.objects.create( + name="Embedded RP / fixed-chassis system board", + match_type="serial_matches_device", + pattern="", + action="transparent", + require_serial_match_parent=False, + enabled=True, + description=( + "Fixed-form routers (e.g. Cisco 8201-SYS, 8100 series) report the system board as " + "an ENTITY-MIB module entry whose serial number equals the device's own serial. " + "Marking the entry 'transparent' hides its row in the sync table while promoting " + "its children (transceivers, fans, PSUs) to device-level bay matching." + ), + ) + + +def _delete_default_rules(apps, schema_editor): + InventoryIgnoreRule = apps.get_model("netbox_librenms_plugin", "InventoryIgnoreRule") + # Delete only the exact seeded defaults — match on all seeded fields so + # admin-created rules that share a name/pattern are not accidentally removed. + InventoryIgnoreRule.objects.filter( + name="Cisco IOS-XR IDPROM entries", + match_type="ends_with", + pattern="IDPROM", + action="skip", + ).delete() + InventoryIgnoreRule.objects.filter( + name="Embedded RP / fixed-chassis system board", + match_type="serial_matches_device", + pattern="", + action="transparent", + ).delete() + + +class Migration(migrations.Migration): + replaces = [ + ("netbox_librenms_plugin", "0009_add_devicetypemapping"), + ("netbox_librenms_plugin", "0010_add_moduletypemapping"), + ("netbox_librenms_plugin", "0011_modulebaymapping"), + ("netbox_librenms_plugin", "0012_add_is_regex_to_modulebaymapping"), + ("netbox_librenms_plugin", "0013_normalizationrule"), + ("netbox_librenms_plugin", "0014_inventoryignorerule"), + ] + + dependencies = [ + ("dcim", "0001_initial"), + ("extras", "0001_initial"), + ("netbox_librenms_plugin", "0008_librenmssettings_import_defaults"), + ] + + operations = [ + # DeviceTypeMapping + migrations.CreateModel( + name="DeviceTypeMapping", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ("created", models.DateTimeField(auto_now_add=True, null=True)), + ("last_updated", models.DateTimeField(auto_now=True, null=True)), + ( + "custom_field_data", + models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder), + ), + ("librenms_hardware", models.CharField(max_length=255, unique=True)), + ("description", models.TextField(blank=True)), + ( + "netbox_device_type", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="librenms_mappings", + to="dcim.devicetype", + ), + ), + ("tags", taggit.managers.TaggableManager(through="extras.TaggedItem", to="extras.Tag")), + ], + options={ + "ordering": ["librenms_hardware"], + }, + bases=(netbox.models.deletion.DeleteMixin, models.Model), + ), + # InterfaceTypeMapping ordering + migrations.AlterModelOptions( + name="interfacetypemapping", + options={"ordering": ["librenms_type", "librenms_speed"]}, + ), + # ModuleTypeMapping + migrations.CreateModel( + name="ModuleTypeMapping", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ("created", models.DateTimeField(auto_now_add=True, null=True)), + ("last_updated", models.DateTimeField(auto_now=True, null=True)), + ( + "custom_field_data", + models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder), + ), + ("librenms_model", models.CharField(max_length=255, unique=True)), + ("description", models.TextField(blank=True)), + ( + "netbox_module_type", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="librenms_mappings", + to="dcim.moduletype", + ), + ), + ("tags", taggit.managers.TaggableManager(through="extras.TaggedItem", to="extras.Tag")), + ], + options={ + "ordering": ["librenms_model"], + }, + bases=(netbox.models.deletion.DeleteMixin, models.Model), + ), + # ModuleBayMapping (with is_regex included) + migrations.CreateModel( + name="ModuleBayMapping", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ("created", models.DateTimeField(auto_now_add=True, null=True)), + ("last_updated", models.DateTimeField(auto_now=True, null=True)), + ( + "custom_field_data", + models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder), + ), + ("librenms_name", models.CharField(max_length=255)), + ("librenms_class", models.CharField(blank=True, max_length=50)), + ("netbox_bay_name", models.CharField(max_length=255)), + ("description", models.TextField(blank=True)), + ( + "is_regex", + models.BooleanField( + default=False, + help_text="Treat LibreNMS Name as a regex pattern with backreferences in NetBox Bay Name", + ), + ), + ("tags", taggit.managers.TaggableManager(through="extras.TaggedItem", to="extras.Tag")), + ], + options={ + "ordering": ["librenms_name"], + "unique_together": {("librenms_name", "librenms_class")}, + }, + bases=(netbox.models.deletion.DeleteMixin, models.Model), + ), + # NormalizationRule + migrations.CreateModel( + name="NormalizationRule", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ("created", models.DateTimeField(auto_now_add=True, null=True)), + ("last_updated", models.DateTimeField(auto_now=True, null=True)), + ( + "custom_field_data", + models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder), + ), + ( + "scope", + models.CharField( + choices=[ + ("module_type", "Module Type"), + ("device_type", "Device Type"), + ("module_bay", "Module Bay"), + ], + max_length=50, + ), + ), + ("match_pattern", models.CharField(max_length=500)), + ("replacement", models.CharField(max_length=500)), + ("priority", models.PositiveIntegerField(default=100)), + ("description", models.TextField(blank=True)), + ( + "manufacturer", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="normalization_rules", + to="dcim.manufacturer", + ), + ), + ("tags", taggit.managers.TaggableManager(through="extras.TaggedItem", to="extras.Tag")), + ], + options={ + "ordering": ["scope", "priority", "pk"], + }, + bases=(netbox.models.deletion.DeleteMixin, models.Model), + ), + # InventoryIgnoreRule + migrations.CreateModel( + name="InventoryIgnoreRule", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ("created", models.DateTimeField(auto_now_add=True, null=True)), + ("last_updated", models.DateTimeField(auto_now=True, null=True)), + ( + "custom_field_data", + models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder), + ), + ("name", models.CharField(max_length=100)), + ( + "match_type", + models.CharField( + choices=[ + ("ends_with", "Ends with (entPhysicalName)"), + ("starts_with", "Starts with (entPhysicalName)"), + ("contains", "Contains (entPhysicalName)"), + ("regex", "Regex (entPhysicalName)"), + ("serial_matches_device", "Serial matches device (entPhysicalSerialNum = Device.serial)"), + ], + default="ends_with", + max_length=25, + ), + ), + ("pattern", models.CharField(blank=True, max_length=200)), + ( + "action", + models.CharField( + choices=[ + ("skip", "Skip (remove from table)"), + ("transparent", "Transparent (hide row, promote children to device level)"), + ], + default="skip", + max_length=15, + ), + ), + ("require_serial_match_parent", models.BooleanField(default=True)), + ("enabled", models.BooleanField(default=True)), + ("description", models.TextField(blank=True)), + ], + options={ + "ordering": ["name", "pk"], + }, + ), + migrations.RunPython(code=_insert_default_rules, reverse_code=_delete_default_rules), + ] diff --git a/netbox_librenms_plugin/models.py b/netbox_librenms_plugin/models.py index cd79f47550..dcf3dffeff 100644 --- a/netbox_librenms_plugin/models.py +++ b/netbox_librenms_plugin/models.py @@ -1,4 +1,8 @@ +import re + from dcim.choices import InterfaceTypeChoices +from dcim.models import DeviceType, Manufacturer, ModuleType +from django.core.exceptions import ValidationError from django.db import models from django.urls import reverse from netbox.models import NetBoxModel @@ -71,6 +75,363 @@ class Meta: """Meta options for InterfaceTypeMapping.""" unique_together = ["librenms_type", "librenms_speed"] + ordering = ["librenms_type", "librenms_speed"] def __str__(self): return f"{self.librenms_type} + {self.librenms_speed} -> {self.netbox_type}" + + +class DeviceTypeMapping(NetBoxModel): + """Map LibreNMS hardware strings to NetBox DeviceType objects.""" + + librenms_hardware = models.CharField( + max_length=255, + unique=True, + help_text="Hardware string as reported by LibreNMS (e.g., 'Juniper MX480 Internet Backbone Router')", + ) + netbox_device_type = models.ForeignKey( + DeviceType, + on_delete=models.CASCADE, + related_name="librenms_mappings", + help_text="The NetBox DeviceType this hardware string maps to", + ) + description = models.TextField( + blank=True, + help_text="Optional description or notes about this mapping", + ) + + def get_absolute_url(self): + """Return the URL for this mapping's detail page.""" + return reverse("plugins:netbox_librenms_plugin:devicetypemapping_detail", args=[self.pk]) + + class Meta: + """Meta options for DeviceTypeMapping.""" + + ordering = ["librenms_hardware"] + + def __str__(self): + return f"{self.librenms_hardware} -> {self.netbox_device_type}" + + +class ModuleTypeMapping(NetBoxModel): + """Map LibreNMS inventory model names to NetBox ModuleType objects.""" + + librenms_model = models.CharField( + max_length=255, + unique=True, + help_text="Model name from LibreNMS inventory (entPhysicalModelName)", + ) + netbox_module_type = models.ForeignKey( + ModuleType, + on_delete=models.CASCADE, + related_name="librenms_mappings", + help_text="The NetBox ModuleType this model name maps to", + ) + description = models.TextField( + blank=True, + help_text="Optional description or notes about this mapping", + ) + + def get_absolute_url(self): + """Return the URL for this mapping's detail page.""" + return reverse("plugins:netbox_librenms_plugin:moduletypemapping_detail", args=[self.pk]) + + class Meta: + """Meta options for ModuleTypeMapping.""" + + ordering = ["librenms_model"] + + def __str__(self): + return f"{self.librenms_model} -> {self.netbox_module_type}" + + +class ModuleBayMapping(NetBoxModel): + """ + Map LibreNMS inventory names to NetBox module bay names. + + Used when LibreNMS inventory names don't match NetBox bay names exactly. + For example: LibreNMS "Power Supply 1" → NetBox "PS1". + When is_regex is True, librenms_name is treated as a regex pattern and + netbox_bay_name can use backreferences (\\1, \\2, etc.). + Mappings are global (not scoped to device type or manufacturer). + """ + + librenms_name = models.CharField( + max_length=255, + help_text="Name from LibreNMS inventory (entPhysicalName). " + "When 'Use Regex' is enabled, this is a Python regex pattern.", + ) + librenms_class = models.CharField( + max_length=50, + blank=True, + help_text="Optional entPhysicalClass filter (e.g. 'powerSupply', 'fan', 'module')", + ) + netbox_bay_name = models.CharField( + max_length=255, + help_text="NetBox module bay name to match. With regex, supports backreferences (\\1, \\2, etc.).", + ) + is_regex = models.BooleanField( + default=False, + help_text="Treat LibreNMS Name as a regex pattern with backreferences in NetBox Bay Name", + ) + description = models.TextField( + blank=True, + help_text="Optional description or notes about this mapping", + ) + + def clean(self): + """Validate that regex patterns compile when is_regex is True.""" + super().clean() + if self.is_regex: + try: + pattern = re.compile(self.librenms_name) + except re.error as e: + raise ValidationError({"librenms_name": f"Invalid regex: {e}"}) + try: + pattern.sub(self.netbox_bay_name, "") + except (re.error, IndexError) as e: + raise ValidationError({"netbox_bay_name": f"Invalid replacement: {e}"}) + + def get_absolute_url(self): + """Return the URL for this mapping's detail page.""" + return reverse("plugins:netbox_librenms_plugin:modulebaymapping_detail", args=[self.pk]) + + class Meta: + """Meta options for ModuleBayMapping.""" + + unique_together = ["librenms_name", "librenms_class"] + ordering = ["librenms_name"] + + def __str__(self): + cls = f" [{self.librenms_class}]" if self.librenms_class else "" + return f"{self.librenms_name}{cls} -> {self.netbox_bay_name}" + + +class NormalizationRule(NetBoxModel): + """ + Regex-based string normalization applied before matching lookups. + + Generic building block: a single rule engine handles normalization + for module types, device types, module bays, and future scopes. + Rules are applied in priority order; each transforms the string + for the next rule in the chain. + + Example – strip Nokia revision suffixes: + scope: module_type + match_pattern: ^(3HE\\w{5}[A-Z]{2})[A-Z]{2}\\d{2}$ + replacement: \\1 + Result: 3HE16474AARA01 → 3HE16474AA + """ + + SCOPE_MODULE_TYPE = "module_type" + SCOPE_DEVICE_TYPE = "device_type" + SCOPE_MODULE_BAY = "module_bay" + + SCOPE_CHOICES = [ + (SCOPE_MODULE_TYPE, "Module Type"), + (SCOPE_DEVICE_TYPE, "Device Type"), + (SCOPE_MODULE_BAY, "Module Bay"), + ] + + scope = models.CharField( + max_length=50, + choices=SCOPE_CHOICES, + help_text="Which matching lookup this rule applies to", + ) + manufacturer = models.ForeignKey( + Manufacturer, + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="normalization_rules", + help_text="Optional: only apply this rule to items from this manufacturer. " + "Leave blank for vendor-agnostic rules.", + ) + match_pattern = models.CharField( + max_length=500, + help_text="Regex pattern to match against input string (Python re syntax)", + ) + replacement = models.CharField( + max_length=500, + help_text="Replacement string (supports regex back-references \\1, \\2, …)", + ) + priority = models.PositiveIntegerField( + default=100, + help_text="Lower values run first. Rules chain: each transforms the output of the previous.", + ) + description = models.TextField( + blank=True, + help_text="Optional description or notes about this rule", + ) + + def clean(self): + """Validate that match_pattern compiles as a regex and replacement is a valid template.""" + super().clean() + try: + compiled = re.compile(self.match_pattern) + except re.error as e: + raise ValidationError({"match_pattern": f"Invalid regex: {e}"}) + # Validate the replacement template by running a dummy substitution + try: + compiled.sub(self.replacement, "") + except re.error as e: + raise ValidationError({"replacement": f"Invalid replacement template: {e}"}) + + def get_absolute_url(self): + """Return the URL for this rule's detail page.""" + return reverse("plugins:netbox_librenms_plugin:normalizationrule_detail", args=[self.pk]) + + class Meta: + """Meta options for NormalizationRule.""" + + ordering = ["scope", "priority", "pk"] + + def __str__(self): + return f"[{self.get_scope_display()}] {self.match_pattern} → {self.replacement}" + + +class InventoryIgnoreRule(NetBoxModel): + """ + Rule-based filter for ENTITY-MIB inventory items during module sync. + + Two use-cases are supported, controlled by the ``action`` field: + + **Skip** (``action='skip'``) + The matched item is removed from the sync table entirely. Used for + phantom EEPROM/IDPROM child entities that Cisco IOS-XR reports with the + same model and serial as the real parent module. + + **Transparent** (``action='transparent'``) + The matched item's row is hidden, but its ENTITY-MIB children are + *promoted* to device-level bay matching instead of being treated as + sub-components. Used for fixed-chassis devices (e.g. Cisco 8201-SYS) + where the RP/system-board entity is the device itself — it carries the + same serial number as the NetBox device, so its children (transceivers, + fans, PSUs) should be matched directly against device-level bays. + + Match types: + ``ends_with / starts_with / contains / regex`` + Compare ``entPhysicalName``. Use ``require_serial_match_parent`` + as a safety net to avoid false positives. + ``serial_matches_device`` + Match when the item's ``entPhysicalSerialNum`` equals the NetBox + device's own serial number. No ``pattern`` is required. + Pair with ``action='transparent'`` for embedded-RP detection. + """ + + # --- action --- + ACTION_SKIP = "skip" + ACTION_TRANSPARENT = "transparent" + ACTION_CHOICES = [ + (ACTION_SKIP, "Skip (remove from table)"), + (ACTION_TRANSPARENT, "Transparent (hide row, promote children to device level)"), + ] + + # --- match_type --- + MATCH_ENDS_WITH = "ends_with" + MATCH_STARTS_WITH = "starts_with" + MATCH_CONTAINS = "contains" + MATCH_REGEX = "regex" + MATCH_SERIAL_DEVICE = "serial_matches_device" + + MATCH_TYPE_CHOICES = [ + (MATCH_ENDS_WITH, "Ends with (entPhysicalName)"), + (MATCH_STARTS_WITH, "Starts with (entPhysicalName)"), + (MATCH_CONTAINS, "Contains (entPhysicalName)"), + (MATCH_REGEX, "Regex (entPhysicalName)"), + (MATCH_SERIAL_DEVICE, "Serial matches device (entPhysicalSerialNum = Device.serial)"), + ] + + name = models.CharField( + max_length=100, + help_text="Short descriptive label for this rule", + ) + match_type = models.CharField( + max_length=25, + choices=MATCH_TYPE_CHOICES, + default=MATCH_ENDS_WITH, + help_text="How to match the inventory item", + ) + pattern = models.CharField( + max_length=200, + blank=True, + help_text="Pattern to match against entPhysicalName. " + "Case-insensitive for ends_with / starts_with / contains; " + "Python re syntax for regex. " + "Not used for serial_matches_device.", + ) + action = models.CharField( + max_length=15, + choices=ACTION_CHOICES, + default=ACTION_SKIP, + help_text="What to do when this rule matches: skip the item entirely, " + "or hide its row and promote its children to device-level bay matching.", + ) + require_serial_match_parent = models.BooleanField( + default=True, + help_text="(Name-based rules only) Only apply this rule if the item's serial " + "number matches an ancestor entity's serial number. Recommended to " + "prevent false positives. Ignored for serial_matches_device rules.", + ) + enabled = models.BooleanField( + default=True, + help_text="Uncheck to temporarily disable this rule without deleting it", + ) + description = models.TextField( + blank=True, + help_text="Optional notes about this rule (vendor, firmware version, etc.)", + ) + + def clean(self): + """Validate pattern/match_type consistency.""" + super().clean() + pattern_stripped = self.pattern.strip() if self.pattern else "" + if self.match_type == self.MATCH_REGEX and pattern_stripped: + try: + re.compile(pattern_stripped) + except re.error as e: + raise ValidationError({"pattern": f"Invalid regex: {e}"}) + if self.match_type != self.MATCH_SERIAL_DEVICE and not pattern_stripped: + raise ValidationError({"pattern": "Pattern is required for name-based match types."}) + + def matches_name(self, name: str) -> bool: + """Return True if *name* matches this rule's pattern/match_type (name-based rules only).""" + if not name or self.match_type == self.MATCH_SERIAL_DEVICE: + return False + if not self.pattern or not self.pattern.strip(): + return False + if self.match_type == self.MATCH_REGEX: + return bool(re.search(self.pattern, name)) + name_up = name.upper() + pat = self.pattern.upper() + if self.match_type == self.MATCH_ENDS_WITH: + return name_up.endswith(pat) + if self.match_type == self.MATCH_STARTS_WITH: + return name_up.startswith(pat) + if self.match_type == self.MATCH_CONTAINS: + return pat in name_up + return False + + def check_match(self, item_name: str, item_serial: str, device_serial: str) -> bool: + """ + Return True if this rule matches the given inventory item. + + For ``serial_matches_device``: compares *item_serial* to *device_serial*. + For all other match types: delegates to :meth:`matches_name`. + """ + if self.match_type == self.MATCH_SERIAL_DEVICE: + return bool(item_serial and device_serial and item_serial == device_serial) + return self.matches_name(item_name) + + def get_absolute_url(self): + """Return the URL for this rule's detail page.""" + return reverse("plugins:netbox_librenms_plugin:inventoryignorerule_detail", args=[self.pk]) + + class Meta: + """Meta options for InventoryIgnoreRule.""" + + ordering = ["name", "pk"] + + def __str__(self): + serial_note = " [serial match]" if self.require_serial_match_parent else "" + return f"{self.name}: {self.get_match_type_display()} '{self.pattern}'{serial_note}" diff --git a/netbox_librenms_plugin/navigation.py b/netbox_librenms_plugin/navigation.py index a08e62740f..25bd4551b2 100644 --- a/netbox_librenms_plugin/navigation.py +++ b/netbox_librenms_plugin/navigation.py @@ -31,6 +31,91 @@ ), ), ), + PluginMenuItem( + link="plugins:netbox_librenms_plugin:devicetypemapping_list", + link_text="Device Type Mappings", + permissions=[PERM_VIEW_PLUGIN], + buttons=( + PluginMenuButton( + link="plugins:netbox_librenms_plugin:devicetypemapping_add", + title="Add", + icon_class="mdi mdi-plus-thick", + ), + PluginMenuButton( + link="plugins:netbox_librenms_plugin:devicetypemapping_bulk_import", + title="Import", + icon_class="mdi mdi-upload", + ), + ), + ), + PluginMenuItem( + link="plugins:netbox_librenms_plugin:moduletypemapping_list", + link_text="Module Type Mappings", + permissions=[PERM_VIEW_PLUGIN], + buttons=( + PluginMenuButton( + link="plugins:netbox_librenms_plugin:moduletypemapping_add", + title="Add", + icon_class="mdi mdi-plus-thick", + ), + PluginMenuButton( + link="plugins:netbox_librenms_plugin:moduletypemapping_bulk_import", + title="Import", + icon_class="mdi mdi-upload", + ), + ), + ), + PluginMenuItem( + link="plugins:netbox_librenms_plugin:modulebaymapping_list", + link_text="Module Bay Mappings", + permissions=[PERM_VIEW_PLUGIN], + buttons=( + PluginMenuButton( + link="plugins:netbox_librenms_plugin:modulebaymapping_add", + title="Add", + icon_class="mdi mdi-plus-thick", + ), + PluginMenuButton( + link="plugins:netbox_librenms_plugin:modulebaymapping_bulk_import", + title="Import", + icon_class="mdi mdi-upload", + ), + ), + ), + PluginMenuItem( + link="plugins:netbox_librenms_plugin:normalizationrule_list", + link_text="Normalization Rules", + permissions=[PERM_VIEW_PLUGIN], + buttons=( + PluginMenuButton( + link="plugins:netbox_librenms_plugin:normalizationrule_add", + title="Add", + icon_class="mdi mdi-plus-thick", + ), + PluginMenuButton( + link="plugins:netbox_librenms_plugin:normalizationrule_bulk_import", + title="Import", + icon_class="mdi mdi-upload", + ), + ), + ), + PluginMenuItem( + link="plugins:netbox_librenms_plugin:inventoryignorerule_list", + link_text="Inventory Ignore Rules", + permissions=[PERM_VIEW_PLUGIN], + buttons=( + PluginMenuButton( + link="plugins:netbox_librenms_plugin:inventoryignorerule_add", + title="Add", + icon_class="mdi mdi-plus-thick", + ), + PluginMenuButton( + link="plugins:netbox_librenms_plugin:inventoryignorerule_bulk_import", + title="Import", + icon_class="mdi mdi-upload", + ), + ), + ), ), ), ( diff --git a/netbox_librenms_plugin/static/netbox_librenms_plugin/js/librenms_sync.js b/netbox_librenms_plugin/static/netbox_librenms_plugin/js/librenms_sync.js index 7545094e63..21d3842fe4 100644 --- a/netbox_librenms_plugin/static/netbox_librenms_plugin/js/librenms_sync.js +++ b/netbox_librenms_plugin/static/netbox_librenms_plugin/js/librenms_sync.js @@ -153,11 +153,15 @@ function initializeCountdowns() { if (window.vlanCountdownInterval) { clearInterval(window.vlanCountdownInterval); } + if (window.moduleCountdownInterval) { + clearInterval(window.moduleCountdownInterval); + } window.interfaceCountdownInterval = initializeCountdown("countdown-timer"); window.cableCountdownInterval = initializeCountdown("cable-countdown-timer"); window.ipCountdownInterval = initializeCountdown("ip-countdown-timer"); window.vlanCountdownInterval = initializeCountdown("vlan-countdown-timer"); + window.moduleCountdownInterval = initializeCountdown("module-countdown-timer"); } // ============================================ @@ -217,6 +221,7 @@ function initializeCheckboxes() { initializeTableCheckboxes('librenms-ipaddress-table'); initializeTableCheckboxes('librenms-vlan-table'); initializeTableCheckboxes('librenms-port-vlan-table'); + initializeTableCheckboxes('librenms-module-table'); } // ============================================ @@ -401,17 +406,21 @@ function openVlanDetailModal(btn) { applyAllCheckbox.checked = false; } - // Show modal via hidden trigger (bootstrap not globally available in NetBox/Tabler) - let trigger = document.getElementById('vlanModalTrigger'); - if (!trigger) { - trigger = document.createElement('button'); - trigger.id = 'vlanModalTrigger'; - trigger.setAttribute('data-bs-toggle', 'modal'); - trigger.setAttribute('data-bs-target', '#vlanDetailModal'); - trigger.style.display = 'none'; - document.body.appendChild(trigger); + // Show modal using direct class manipulation (consistent with openBulkVCModal) + const vlanModal = document.getElementById('vlanDetailModal'); + if (vlanModal) { + vlanModal.classList.add('show'); + vlanModal.style.display = 'block'; + vlanModal.setAttribute('aria-modal', 'true'); + vlanModal.removeAttribute('aria-hidden'); + let backdrop = document.querySelector('.modal-backdrop'); + if (!backdrop) { + backdrop = document.createElement('div'); + backdrop.className = 'modal-backdrop fade show'; + document.body.appendChild(backdrop); + } + document.body.classList.add('modal-open'); } - trigger.click(); } /** @@ -661,11 +670,7 @@ function initializeVlanModalSave() { }) }).then(response => { if (!response.ok) { - return response.text().then(t => { - let msg = `HTTP ${response.status}`; - try { const data = JSON.parse(t); if (data.message) msg = data.message; } catch (_) {} - throw new Error(msg); - }); + return response.text().then(t => { throw new Error(`HTTP ${response.status}: ${t}`); }); } // Apply DOM mutations only after the server has persisted the overrides applyButtonUpdates(); @@ -799,7 +804,6 @@ function handleVRFChange(select, value) { return; } const deviceId = deviceInfo.id; - const row = select.closest('tr'); fetch('/plugins/librenms_plugin/verify-ipaddress/', { method: 'POST', @@ -821,6 +825,8 @@ function handleVRFChange(select, value) { return response.json(); }) .then(data => { + const row = document.querySelector(`tr[data-interface="${select.dataset.rowId}"]`); + if (data.status === 'success' && row && data.formatted_row) { const statusCell = row.querySelector('td[data-col="status"]'); if (statusCell) { @@ -1637,6 +1643,150 @@ function closeHtmxModal() { * Initialize all sync page functionality. * Called on DOMContentLoaded and after HTMX content swaps. */ +/** + * Wire the "Install Selected" form to collect checked module-table rows before submit. + * The form is separate from the table (to avoid nested forms), so we copy the + * selected checkbox values into hidden inputs just before the form is submitted. + * Guard against duplicate listeners on repeated HTMX swaps via a data attribute. + */ +function handleInstallSelectedSubmit() { + // Remove any previously-injected hidden inputs to avoid duplicates + const form = document.getElementById('install-selected-form'); + if (!form) return; + form.querySelectorAll('input[data-injected-select]').forEach(el => { el.remove(); }); + + const table = document.getElementById('librenms-module-table'); + if (!table) return; + + table.querySelectorAll('input[name="select"]:checked').forEach(cb => { + const hidden = document.createElement('input'); + hidden.type = 'hidden'; + hidden.name = 'select'; + hidden.value = cb.value; + hidden.dataset.injectedSelect = '1'; + form.appendChild(hidden); + }); +} + +function initializeInstallSelectedForm() { + const form = document.getElementById('install-selected-form'); + if (!form) return; + if (form.dataset.installInit) return; + form.dataset.installInit = 'true'; + form.addEventListener('submit', handleInstallSelectedSubmit); +} + +/** + * Tracks the in-flight AbortController for the module replace preview fetch. + * Cancelled when a new Replace button is clicked before the previous fetch completes. + */ +let _activeReplaceController = null; + +/** + * Initialize Replace buttons on the module sync table. + * Each button carries module/ent_index/server_key as data attributes and opens + * the mismatch comparison modal by fetching the preview fragment from the server. + */ +function initializeModuleReplaceButtons() { + document.querySelectorAll('.module-replace-btn').forEach(btn => { + if (btn.dataset.replaceInitialized) return; + btn.dataset.replaceInitialized = 'true'; + + btn.addEventListener('click', function () { + // Cancel any in-flight preview request before starting a new one + if (_activeReplaceController) { + _activeReplaceController.abort(); + } + _activeReplaceController = new AbortController(); + const signal = _activeReplaceController.signal; + + const previewUrl = this.dataset.previewUrl; + const moduleId = this.dataset.moduleId; + const entIndex = this.dataset.entIndex; + const serverKey = this.dataset.serverKey; + + const params = new URLSearchParams({ + module_id: moduleId, + ent_index: entIndex, + server_key: serverKey, + }); + + // Show shared HTMX modal with loading state + const modalContent = document.getElementById('htmx-modal-content'); + if (modalContent) { + modalContent.innerHTML = + '' + + ''; + } + + // Show modal using direct class manipulation (consistent with openBulkVCModal) + const htmxModal = document.getElementById('htmx-modal'); + if (htmxModal) { + htmxModal.classList.add('show'); + htmxModal.style.display = 'block'; + htmxModal.setAttribute('aria-modal', 'true'); + htmxModal.removeAttribute('aria-hidden'); + let backdrop = document.querySelector('.modal-backdrop'); + if (!backdrop) { + backdrop = document.createElement('div'); + backdrop.className = 'modal-backdrop fade show'; + document.body.appendChild(backdrop); + } + document.body.classList.add('modal-open'); + } + + // Fetch preview content and inject into modal body + fetch(`${previewUrl}?${params.toString()}`, { + signal, + headers: { 'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]').value }, + }) + .then(response => { + if (!response.ok) return response.text().then(t => { throw new Error(t); }); + return response.text(); + }) + .then(html => { + const modalBody = document.getElementById('htmx-modal-body'); + if (modalBody) { + modalBody.innerHTML = html; + } + }) + .catch(err => { + if (err.name === 'AbortError') return; // Superseded by a newer click — ignore + const modalBody = document.getElementById('htmx-modal-body'); + if (modalBody) { + const alert = document.createElement('div'); + alert.className = 'alert alert-danger'; + const icon = document.createElement('i'); + icon.className = 'mdi mdi-alert me-1'; + alert.appendChild(icon); + alert.appendChild(document.createTextNode(err.message || 'Failed to load preview.')); + modalBody.textContent = ''; + modalBody.appendChild(alert); + } + }); + }); + }); +} + +function closeHtmxModal() { + const htmxModal = document.getElementById('htmx-modal'); + if (htmxModal) { + htmxModal.classList.remove('show'); + htmxModal.style.display = 'none'; + htmxModal.setAttribute('aria-hidden', 'true'); + htmxModal.removeAttribute('aria-modal'); + const backdrop = document.querySelector('.modal-backdrop'); + if (backdrop) backdrop.remove(); + document.body.classList.remove('modal-open'); + } +} + function initializeScripts() { initializeCheckboxes(); initializeVCMemberSelect(); diff --git a/netbox_librenms_plugin/tables/mappings.py b/netbox_librenms_plugin/tables/mappings.py index 73949fd2c8..7ba77186e2 100644 --- a/netbox_librenms_plugin/tables/mappings.py +++ b/netbox_librenms_plugin/tables/mappings.py @@ -1,7 +1,15 @@ import django_tables2 as tables +from django.utils.html import format_html, mark_safe from netbox.tables import NetBoxTable, columns -from netbox_librenms_plugin.models import InterfaceTypeMapping +from netbox_librenms_plugin.models import ( + DeviceTypeMapping, + InterfaceTypeMapping, + InventoryIgnoreRule, + ModuleBayMapping, + ModuleTypeMapping, + NormalizationRule, +) class InterfaceTypeMappingTable(NetBoxTable): @@ -36,3 +44,192 @@ class Meta: "actions", ) attrs = {"class": "table table-hover table-headings table-striped"} + + +class DeviceTypeMappingTable(NetBoxTable): + """Table for displaying DeviceTypeMapping data.""" + + librenms_hardware = tables.Column(verbose_name="LibreNMS Hardware", linkify=True) + netbox_device_type = tables.Column(verbose_name="NetBox Device Type", linkify=True) + description = tables.Column(verbose_name="Description", linkify=False) + actions = columns.ActionsColumn(actions=("edit", "delete")) + + class Meta: + """Meta options for DeviceTypeMappingTable.""" + + model = DeviceTypeMapping + fields = ( + "id", + "librenms_hardware", + "netbox_device_type", + "description", + "actions", + ) + default_columns = ( + "id", + "librenms_hardware", + "netbox_device_type", + "description", + "actions", + ) + attrs = {"class": "table table-hover table-headings table-striped"} + + +class ModuleTypeMappingTable(NetBoxTable): + """Table for displaying ModuleTypeMapping data.""" + + librenms_model = tables.Column(verbose_name="LibreNMS Model", linkify=True) + netbox_module_type = tables.Column(verbose_name="NetBox Module Type", linkify=True) + description = tables.Column(verbose_name="Description", linkify=False) + actions = columns.ActionsColumn(actions=("edit", "delete")) + + class Meta: + """Meta options for ModuleTypeMappingTable.""" + + model = ModuleTypeMapping + fields = ( + "id", + "librenms_model", + "netbox_module_type", + "description", + "actions", + ) + default_columns = ( + "id", + "librenms_model", + "netbox_module_type", + "description", + "actions", + ) + attrs = {"class": "table table-hover table-headings table-striped"} + + +class ModuleBayMappingTable(NetBoxTable): + """Table for displaying ModuleBayMapping data.""" + + librenms_name = tables.Column(verbose_name="LibreNMS Name", linkify=True) + librenms_class = tables.Column(verbose_name="LibreNMS Class") + netbox_bay_name = tables.Column(verbose_name="NetBox Bay Name") + is_regex = columns.BooleanColumn(verbose_name="Regex") + description = tables.Column(verbose_name="Description", linkify=False) + actions = columns.ActionsColumn(actions=("edit", "delete")) + + class Meta: + """Meta options for ModuleBayMappingTable.""" + + model = ModuleBayMapping + fields = ( + "id", + "librenms_name", + "librenms_class", + "netbox_bay_name", + "is_regex", + "description", + "actions", + ) + default_columns = ( + "id", + "librenms_name", + "librenms_class", + "netbox_bay_name", + "is_regex", + "description", + "actions", + ) + attrs = {"class": "table table-hover table-headings table-striped"} + + +class NormalizationRuleTable(NetBoxTable): + """Table for displaying NormalizationRule data.""" + + scope = tables.Column(verbose_name="Scope", linkify=True) + manufacturer = tables.Column(verbose_name="Manufacturer", linkify=True) + match_pattern = tables.Column(verbose_name="Match Pattern") + replacement = tables.Column(verbose_name="Replacement") + priority = tables.Column(verbose_name="Priority") + description = tables.Column(verbose_name="Description", linkify=False) + actions = columns.ActionsColumn(actions=("edit", "delete")) + + class Meta: + """Meta options for NormalizationRuleTable.""" + + model = NormalizationRule + fields = ( + "id", + "scope", + "manufacturer", + "match_pattern", + "replacement", + "priority", + "description", + "actions", + ) + default_columns = ( + "id", + "scope", + "match_pattern", + "replacement", + "priority", + "actions", + ) + attrs = {"class": "table table-hover table-headings table-striped"} + + +class InventoryIgnoreRuleTable(NetBoxTable): + """Table for displaying InventoryIgnoreRule data.""" + + name = tables.Column(verbose_name="Name", linkify=True) + match_type = tables.Column(verbose_name="Match Type") + action = tables.Column(verbose_name="Action") + pattern = tables.Column(verbose_name="Pattern") + require_serial_match_parent = tables.BooleanColumn(verbose_name="Require Serial Match") + enabled = tables.BooleanColumn(verbose_name="Enabled") + description = tables.Column(verbose_name="Description", linkify=False) + actions = columns.ActionsColumn(actions=("edit", "delete")) + + def render_action(self, value, record): + """Display the human-readable action label.""" + return record.get_action_display() + + def render_pattern(self, value, record): + """Show dash for serial_matches_device rules where pattern is unused.""" + if record.match_type == "serial_matches_device": + return mark_safe('') + return format_html("{}", value) if value else "—" + + def render_require_serial_match_parent(self, value, record): + """Show dash for serial_matches_device rules where this flag is unused.""" + if record.match_type == "serial_matches_device": + return mark_safe('') + return ( + mark_safe('Yes') + if value + else mark_safe('No') + ) + + class Meta: + """Meta options for InventoryIgnoreRuleTable.""" + + model = InventoryIgnoreRule + fields = ( + "id", + "name", + "match_type", + "action", + "pattern", + "require_serial_match_parent", + "enabled", + "description", + "actions", + ) + default_columns = ( + "id", + "name", + "match_type", + "action", + "pattern", + "require_serial_match_parent", + "enabled", + "actions", + ) + attrs = {"class": "table table-hover table-headings table-striped"} diff --git a/netbox_librenms_plugin/tables/modules.py b/netbox_librenms_plugin/tables/modules.py new file mode 100644 index 0000000000..085d32aac2 --- /dev/null +++ b/netbox_librenms_plugin/tables/modules.py @@ -0,0 +1,261 @@ +import django_tables2 as tables +from django.urls import reverse +from django.utils.html import format_html, mark_safe +from netbox.tables.columns import ToggleColumn +from utilities.paginator import EnhancedPaginator + +from netbox_librenms_plugin.utils import get_table_paginate_count + + +class LibreNMSModuleTable(tables.Table): + """Table for displaying LibreNMS inventory items mapped to NetBox modules.""" + + selection = ToggleColumn( + orderable=False, + visible=True, + accessor="ent_physical_index", + attrs={"td": {"data-col": "selection"}, "input": {"name": "select"}}, + ) + name = tables.Column( + verbose_name="Name", + attrs={ + "td": {"data-col": "name"}, + "th": { + "title": "Name from ENTITY-MIB (entPhysicalName). May differ from interface names in ifDescr/ifName." + }, + }, + ) + model = tables.Column(verbose_name="Model", attrs={"td": {"data-col": "model"}}) + serial = tables.Column(verbose_name="Serial", attrs={"td": {"data-col": "serial"}}) + description = tables.Column(verbose_name="Description", attrs={"td": {"data-col": "description"}}) + item_class = tables.Column(verbose_name="Class", attrs={"td": {"data-col": "item_class"}}) + module_bay = tables.Column(verbose_name="Module Bay", attrs={"td": {"data-col": "module_bay"}}) + module_type = tables.Column(verbose_name="Module Type", attrs={"td": {"data-col": "module_type"}}) + status = tables.Column(verbose_name="Status", attrs={"td": {"data-col": "status"}}) + actions = tables.Column( + verbose_name="Actions", orderable=False, empty_values=(), attrs={"td": {"data-col": "actions"}} + ) + + class Meta: + attrs = {"class": "table table-hover object-list", "id": "librenms-module-table"} + row_attrs = { + "class": lambda record: record.get("row_class", ""), + "data-ent-index": lambda record: record.get("ent_physical_index", ""), + "data-status": lambda record: record.get("status", ""), + "data-depth": lambda record: str(record.get("depth", 0)), + "data-item-class": lambda record: record.get("item_class", ""), + } + + def __init__(self, *args, device=None, server_key="", **kwargs): + """Initialize table with optional device context.""" + self.device = device + self.csrf_token = "" + self.server_key = server_key + super().__init__(*args, **kwargs) + self.tab = "modules" + self.htmx_url = None + self.prefix = "modules_" + + def configure(self, request): + """Configure pagination settings and CSRF token.""" + from django.middleware.csrf import get_token + + self.csrf_token = get_token(request) + paginate = {"paginator_class": EnhancedPaginator, "per_page": get_table_paginate_count(request, self.prefix)} + tables.RequestConfig(request, paginate).configure(self) + + def render_name(self, value, record): + """Render inventory item name with tree indentation for sub-components.""" + depth = record.get("depth", 0) + if depth == 0: + return value or "-" + # Build visual tree prefix based on nesting depth + padding_px = depth * 20 + prefix = "└─ " + return format_html('{}{}', padding_px, prefix, value or "-") + + def render_model(self, value, record): + """Render model with link to module type if matched.""" + if not value or value == "-": + return "-" + if url := record.get("module_type_url"): + return format_html('{}', url, value) + return value + + def render_serial(self, value, record): + """Render serial number.""" + return value or "-" + + def render_description(self, value, record): + """Render description, truncated for display.""" + if not value: + return "-" + if len(value) > 60: + return format_html('{}…', value, value[:57]) + return value + + def render_item_class(self, value, record): + """Render the entPhysicalClass with an icon.""" + icons = { + "module": "mdi-expansion-card", + "ioModule": "mdi-expansion-card", + "cpmModule": "mdi-expansion-card", + "mdaModule": "mdi-expansion-card", + "fabricModule": "mdi-expansion-card", + "xioModule": "mdi-expansion-card", + "powerSupply": "mdi-power-plug", + "fan": "mdi-fan", + "port": "mdi-ethernet", + "other": "mdi-card-outline", + } + icon = icons.get(value, "mdi-card-outline") + return format_html(' {}', icon, value) + + def render_module_bay(self, value, record): + """Render module bay with link if found in NetBox.""" + if not value or value == "-": + return mark_safe('No matching bay') + if url := record.get("module_bay_url"): + return format_html('{}', url, value) + return value + + def render_module_type(self, value, record): + """Render module type match status.""" + if not value or value == "-": + return mark_safe('No matching type') + if url := record.get("module_type_url"): + return format_html('{}', url, value) + return value + + def render_status(self, value, record): + """Render sync status with badge.""" + badge_classes = { + "Installed": "bg-success", + "Matched": "bg-info", + "No Bay": "bg-warning", + "No Type": "bg-warning", + "Unmatched": "bg-secondary", + "Serial Mismatch": "bg-danger", + "Name Conflict": "bg-warning", + } + badge_class = badge_classes.get(value, "bg-secondary") + if warning := record.get("module_path_warning"): + return format_html( + '{}' + ' ', + badge_class, + warning, + value, + "Upgrade NetBox to support module bays", + ) + if warning := record.get("name_conflict_warning"): + return format_html( + '{}' + ' ', + badge_class, + warning, + value, + warning, + ) + if hint := record.get("module_type_upgrade_hint"): + return format_html( + '{} ', + badge_class, + value, + hint, + ) + return format_html('{}', badge_class, value) + + def render_actions(self, value, record): + """Render install button for matched modules and install branch for parents.""" + if not self.device: + return "" + + buttons = [] + + # Single install button + if record.get("can_install"): + url = reverse("plugins:netbox_librenms_plugin:install_module", kwargs={"pk": self.device.pk}) + buttons.append( + format_html( + '
' + '' + '' + '' + '' + '' + '
", + url, + self.csrf_token, + self.server_key, + record.get("module_bay_id", ""), + record.get("module_type_id", ""), + record.get("serial", ""), + ) + ) + + # Install branch button for parents with installable children + if record.get("has_installable_children") and record.get("ent_physical_index"): + url = reverse("plugins:netbox_librenms_plugin:install_branch", kwargs={"pk": self.device.pk}) + buttons.append( + format_html( + '
' + '' + '' + '' + '
", + url, + self.csrf_token, + self.server_key, + record.get("ent_physical_index", ""), + ) + ) + + # Update serial button for serial mismatch rows + if record.get("can_update_serial") and record.get("installed_module_id"): + url = reverse("plugins:netbox_librenms_plugin:update_module_serial", kwargs={"pk": self.device.pk}) + buttons.append( + format_html( + '
' + '' + '' + '' + '' + '
", + url, + self.csrf_token, + self.server_key, + record["installed_module_id"], + record.get("serial", ""), + ) + ) + + # Replace button for type/serial mismatch rows — opens comparison modal + if record.get("can_replace") and record.get("installed_module_id"): + preview_url = reverse( + "plugins:netbox_librenms_plugin:module_mismatch_preview", kwargs={"pk": self.device.pk} + ) + buttons.append( + format_html( + '", + record["installed_module_id"], + record.get("ent_physical_index", ""), + self.server_key or "", + preview_url, + ) + ) + + return mark_safe("".join(str(b) for b in buttons)) if buttons else "" diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/_module_sync.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/_module_sync.html new file mode 100644 index 0000000000..da58ecd08d --- /dev/null +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/_module_sync.html @@ -0,0 +1,28 @@ +{% load helpers %} + + +
+

Module Sync

+
+
+ {% csrf_token %} + + {% if has_librenms_id %} + {% with model_name=object|meta:"model_name" %} + {% if model_name == "device" %} + + {% endif %} + {% endwith %} + {% endif %} +
+
+
+ + +
+ {% include 'netbox_librenms_plugin/_module_sync_content.html' %} +
diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/_module_sync_content.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/_module_sync_content.html new file mode 100644 index 0000000000..afd1dd5c28 --- /dev/null +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/_module_sync_content.html @@ -0,0 +1,42 @@ +{% load helpers %} +{% include 'inc/messages.html' %} + + +{% if module_sync.table %} +
+
+ + Showing inventory items from LibreNMS matched against NetBox module bays and module types. + +
+ {% if module_sync.cache_expiry %} +
+ Cache expires in: +
+ {% endif %} +
+ +{# Separate form for Install Selected — uses JS to collect checked rows before submit #} +
+ {% csrf_token %} + +
+ +
+
+
+ {% include 'netbox_librenms_plugin/inc/paginator.html' with table=module_sync.table %} + {% include 'inc/table.html' with table=module_sync.table %} + {% include 'netbox_librenms_plugin/inc/paginator.html' with table=module_sync.table %} +
+{% else %} +
+
+ +

No inventory data loaded. Click Refresh Modules to fetch data from LibreNMS.

+
+
+{% endif %} diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/devicetypemapping.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/devicetypemapping.html new file mode 100644 index 0000000000..3894441179 --- /dev/null +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/devicetypemapping.html @@ -0,0 +1,28 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} + +{% block content %} +
+
+
+ + + + + + + + + + + + + + + +
LibreNMS HardwareNetBox Device TypeDescription
{{ object.librenms_hardware }}{{ object.netbox_device_type }}{{ object.description|default:"—" }}
+
+
+
+{% endblock %} diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/devicetypemapping_list.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/devicetypemapping_list.html new file mode 100644 index 0000000000..06c95270b3 --- /dev/null +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/devicetypemapping_list.html @@ -0,0 +1,12 @@ +{% extends 'generic/object_list.html' %} + +{% block content %} +
+

Device Type Mapping

+

Map LibreNMS hardware strings to NetBox device types. + When importing devices from LibreNMS, these mappings are checked first before + falling back to exact part number / model matching.

+

Example: Map "Juniper MX480 Internet Backbone Router" to device type "MX480"

+
+ {{ block.super }} +{% endblock %} diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_validation_details.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_validation_details.html index fbaa3a7597..90e19bb7ad 100644 --- a/netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_validation_details.html +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_validation_details.html @@ -135,7 +135,6 @@
{# Show only for non-VM contexts: true when no existing device (new import, not VM) OR when existing device is not a VM. #} {% if not validation.existing_device and not validation.import_as_vm or validation.existing_device and existing_device_model_name != "virtualmachine" %} - {# Site row #} Site @@ -279,7 +278,7 @@
{{ validation.existing_device.platform }} {% if sync_info and not sync_info.platform_synced %} {% if sync_info.platform_info.platform_exists %} - {% if not validation.import_as_vm and not validation.existing_device.cluster %} + {% if not validation.existing_device.cluster %}
@@ -305,7 +304,7 @@
{% elif sync_info and sync_info.platform_info.platform_exists %} Not set - {% if validation.existing_device and not validation.import_as_vm and not validation.existing_device.cluster %} + {% if validation.existing_device and not validation.existing_device.cluster %} @@ -464,7 +463,7 @@
Import blocked: The incoming serial number is already assigned to another device in NetBox. Resolve the duplicate serial before linking. - {% elif validation.import_as_vm %} + {% elif validation.import_as_vm or existing_device_model_name == "virtualmachine" %}
Hostname match found for a VM — use the import action to proceed. diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/module_mismatch_modal.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/module_mismatch_modal.html new file mode 100644 index 0000000000..aa6c7585b1 --- /dev/null +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/module_mismatch_modal.html @@ -0,0 +1,110 @@ +{% load helpers %} +{# Modal body fragment for the module replace / move dialog. #} +{# Rendered server-side by ModuleMismatchPreviewView and injected into #htmx-modal-body via JS. #} + +

Comparing the module currently in NetBox with the LibreNMS inventory data.

+ + + + + + + + + + + + + + + + + + + + + + + + + +
Currently in NetBoxFrom LibreNMS
Bay{{ bay_name }}
Module Type + {% if installed_module %} + {{ installed_module.module_type.model }} + {% else %}-{% endif %} + {{ librenms_model }}
Serial{{ installed_serial|default:"-" }}{{ librenms_serial|default:"-" }}
+ +{% if type_mismatch %} +
+ + Different module type — the installed module type does not match LibreNMS. + Replacing will delete the current module and install {{ librenms_model }}. +
+{% elif serial_mismatch %} +
+ + Same module type, different serial — the module may have been physically replaced. +
+{% endif %} + +{% if serial_conflict %} +
+ + Serial conflict: {{ librenms_serial }} is currently installed at + {{ serial_conflict.device.name }} / + Bay: {{ serial_conflict.module_bay.name }}. +
+ Replace will also remove it from that location. + Move will update its location to this bay instead of creating a new entry. + +
+{% endif %} + +
+ + + {% if serial_mismatch and not type_mismatch %} + {# Quick serial-only update — no delete/recreate needed #} + + {% csrf_token %} + + + + + {% endif %} + + {% if serial_conflict %} + {# Move the existing module here rather than creating a new entry #} +
+ {% csrf_token %} + + + + +
+ {% endif %} + + {# Replace: delete current + install fresh from LibreNMS data #} +
+ {% csrf_token %} + + + + {% if serial_conflict %} + + {% endif %} + +
+
diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/inc/_module_sync.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/inc/_module_sync.html new file mode 100644 index 0000000000..809c488af4 --- /dev/null +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/inc/_module_sync.html @@ -0,0 +1,28 @@ +{% load helpers %} + + +
+

Module Sync

+
+
+ {% csrf_token %} + + {% if has_librenms_id %} + {% with model_name=object|meta:"model_name" %} + {% if model_name == "device" %} + + {% endif %} + {% endwith %} + {% endif %} +
+
+
+ + +
+ {% include 'netbox_librenms_plugin/_module_sync_content.html' %} +
diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/inventoryignorerule.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/inventoryignorerule.html new file mode 100644 index 0000000000..62db37c672 --- /dev/null +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/inventoryignorerule.html @@ -0,0 +1,36 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} + +{% block content %} +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + +
NameMatch TypePatternActionRequire Serial MatchEnabledDescription
{{ object.name }}{{ object.get_match_type_display }}{% if object.match_type == "serial_matches_device" %}{% else %}{{ object.pattern }}{% endif %}{{ object.get_action_display }}{% if object.match_type == "serial_matches_device" %}{% elif object.require_serial_match_parent %}Yes{% else %}No{% endif %}{% if object.enabled %}Yes{% else %}No{% endif %}{{ object.description|default:"—" }}
+
+
+
+{% endblock %} diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/inventoryignorerule_list.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/inventoryignorerule_list.html new file mode 100644 index 0000000000..05d49e194b --- /dev/null +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/inventoryignorerule_list.html @@ -0,0 +1,22 @@ +{% extends 'generic/object_list.html' %} + +{% block content %} +
+

Inventory Ignore Rules

+

Configurable rules to skip ENTITY-MIB entries during module sync. + Some vendors (e.g. Cisco IOS-XR) report EEPROM/IDPROM chips as child + entities with the same model name and serial as their parent hardware. + These phantom entries would appear as duplicate modules in the sync UI.

+

Each rule matches an entity name using the selected match type (ends with, + starts with, contains, or regex). When Require Serial Match + is enabled, the entry is only skipped if its serial number matches the + parent entity — providing a safety net against accidentally hiding + legitimate modules.

+

Example — Cisco IOS-XR IDPROM entries:
+ Match type: ends_with, Pattern: IDPROM, + Require serial match: Yes
+ Skips entries like Optics0/0/0/0-IDPROM, + 0/FT0-FT IDPROM, Rack 0-Chassis IDPROM.

+
+ {{ block.super }} +{% endblock %} diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html index d326e0f697..899904e6da 100644 --- a/netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html @@ -73,6 +73,7 @@ {% if not mapping.is_configured %} {% if lookup_device_model_name == "device" or lookup_device_model_name == "virtualmachine" %} + {% if lookup_device_pk == object.pk %}
Remove
+ {% else %} + + Managed by VC sync device + + {% endif %} {% endif %} {% endif %} @@ -620,6 +626,14 @@
Device Information Sync
{% endif %} {% endwith %} + {% if module_sync and object|meta:"model_name" == "device" %} + + {% endif %}
Device Information Sync
{% include 'netbox_librenms_plugin/_ipaddress_sync.html' %} + {% if module_sync and object|meta:"model_name" == "device" %} +
+ {% include 'netbox_librenms_plugin/_module_sync.html' %} +
+ {% endif %} + {% with model_name=object|meta:"model_name" %} {% if model_name == "device" %}
Device Information Sync
+ + + {% if mismatched_device %} +{% if has_write_permission %} {# Separate form for Install Selected — uses JS to collect checked rows before submit #}
@@ -27,6 +28,7 @@
+{% endif %}
{% include 'netbox_librenms_plugin/inc/paginator.html' with table=module_sync.table %} {% include 'inc/table.html' with table=module_sync.table %} diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/inventoryignorerule_list.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/inventoryignorerule_list.html index 05d49e194b..cab8da755e 100644 --- a/netbox_librenms_plugin/templates/netbox_librenms_plugin/inventoryignorerule_list.html +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/inventoryignorerule_list.html @@ -7,9 +7,15 @@

Inventory Ignore Rules

Some vendors (e.g. Cisco IOS-XR) report EEPROM/IDPROM chips as child entities with the same model name and serial as their parent hardware. These phantom entries would appear as duplicate modules in the sync UI.

-

Each rule matches an entity name using the selected match type (ends with, - starts with, contains, or regex). When Require Serial Match - is enabled, the entry is only skipped if its serial number matches the +

Each rule matches an entity using one of the following strategies:

+
    +
  • ends_with / starts_with / contains / regex — matches the entity name string.
  • +
  • serial_matches_device — matches when the entity's serial number is identical to + the parent device's serial. Useful for suppressing EEPROM/IDPROM phantom entries that share the + device serial.
  • +
+

When Require Serial Match + is enabled (for name-based rules), the entry is only skipped if its serial number also matches the parent entity — providing a safety net against accidentally hiding legitimate modules.

Example — Cisco IOS-XR IDPROM entries:
diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html index 899904e6da..9cd24b6e4a 100644 --- a/netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html @@ -730,10 +730,11 @@

Device Information Sync
- {{ block.super }} {% endblock %} + +{% block bulk_buttons %} + {{ block.super }} + +{% endblock %} diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/modulebaymapping_list.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/modulebaymapping_list.html index fb87f901ec..64473b9ece 100644 --- a/netbox_librenms_plugin/templates/netbox_librenms_plugin/modulebaymapping_list.html +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/modulebaymapping_list.html @@ -10,3 +10,10 @@

Module Bay Mapping

{{ block.super }} {% endblock %} + +{% block bulk_buttons %} + {{ block.super }} + +{% endblock %} diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/moduletypemapping_list.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/moduletypemapping_list.html index 4cfc22d592..672298e63e 100644 --- a/netbox_librenms_plugin/templates/netbox_librenms_plugin/moduletypemapping_list.html +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/moduletypemapping_list.html @@ -10,3 +10,10 @@

Module Type Mapping

{{ block.super }} {% endblock %} + +{% block bulk_buttons %} + {{ block.super }} + +{% endblock %} diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/normalizationrule_list.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/normalizationrule_list.html index d543141680..ec39217fe0 100644 --- a/netbox_librenms_plugin/templates/netbox_librenms_plugin/normalizationrule_list.html +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/normalizationrule_list.html @@ -14,3 +14,10 @@

Normalization Rules

{{ block.super }} {% endblock %} + +{% block bulk_buttons %} + {{ block.super }} + +{% endblock %} diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/platformmapping.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/platformmapping.html new file mode 100644 index 0000000000..4511fd7474 --- /dev/null +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/platformmapping.html @@ -0,0 +1,28 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} + +{% block content %} +
+
+
+ + + + + + + + + + + + + + + +
LibreNMS OSNetBox PlatformDescription
{{ object.librenms_os }}{% if object.netbox_platform %}{{ object.netbox_platform }}{% else %}{% endif %}{{ object.description|default:"—" }}
+
+
+
+{% endblock %} diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/platformmapping_list.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/platformmapping_list.html new file mode 100644 index 0000000000..0a625c96eb --- /dev/null +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/platformmapping_list.html @@ -0,0 +1,20 @@ +{% extends 'generic/object_list.html' %} + +{% block content %} +
+

Platform Mappings

+

Map LibreNMS OS strings to NetBox Platforms. During device import, + the plugin checks these mappings before falling back to exact Platform + name matching. This is useful when LibreNMS reports an OS name that + differs from the corresponding NetBox Platform name.

+

Example: LibreNMS OS iosxe → NetBox Platform Cisco IOS-XE.

+
+ {{ block.super }} +{% endblock %} + +{% block bulk_buttons %} + {{ block.super }} + +{% endblock %} diff --git a/netbox_librenms_plugin/tests/test_coverage_utils.py b/netbox_librenms_plugin/tests/test_coverage_utils.py index b190a747f9..ea07825f56 100644 --- a/netbox_librenms_plugin/tests/test_coverage_utils.py +++ b/netbox_librenms_plugin/tests/test_coverage_utils.py @@ -457,16 +457,21 @@ def test_multiple_objects_returned_uses_first(self): mock_platform = MagicMock() Platform_DoesNotExist = type("DoesNotExist", (Exception,), {}) Platform_MultipleObjectsReturned = type("MultipleObjectsReturned", (Exception,), {}) + PlatformMapping_DoesNotExist = type("DoesNotExist", (Exception,), {}) - with patch("dcim.models.Platform") as MockPlatform: - MockPlatform.DoesNotExist = Platform_DoesNotExist - MockPlatform.MultipleObjectsReturned = Platform_MultipleObjectsReturned - MockPlatform.objects.get.side_effect = Platform_MultipleObjectsReturned("multiple") - MockPlatform.objects.filter.return_value.first.return_value = mock_platform + with patch("netbox_librenms_plugin.utils.PlatformMapping") as MockPlatformMapping: + MockPlatformMapping.DoesNotExist = PlatformMapping_DoesNotExist + MockPlatformMapping.objects.get.side_effect = PlatformMapping_DoesNotExist("no mapping") - result = find_matching_platform("ios") - assert result["found"] is True - assert result["platform"] is mock_platform + with patch("dcim.models.Platform") as MockPlatform: + MockPlatform.DoesNotExist = Platform_DoesNotExist + MockPlatform.MultipleObjectsReturned = Platform_MultipleObjectsReturned + MockPlatform.objects.get.side_effect = Platform_MultipleObjectsReturned("multiple") + MockPlatform.objects.filter.return_value.first.return_value = mock_platform + + result = find_matching_platform("ios") + assert result["found"] is True + assert result["platform"] is mock_platform class TestGetMissingVlanWarning: diff --git a/netbox_librenms_plugin/tests/test_platform_mapping.py b/netbox_librenms_plugin/tests/test_platform_mapping.py new file mode 100644 index 0000000000..cd2d2987f3 --- /dev/null +++ b/netbox_librenms_plugin/tests/test_platform_mapping.py @@ -0,0 +1,527 @@ +""" +Tests for PlatformMapping model, to_yaml() on all mapping models, +find_matching_platform ordering, and BulkExportYAML view. + +TDD: these tests are written before implementation. +""" + +from unittest.mock import MagicMock, patch + + +def _set_fk_cache(instance, field_name, value): + """Set a FK field value directly via Django's _state.fields_cache, bypassing descriptor validation.""" + from django.db.models.base import ModelState + + if not hasattr(instance, "_state"): + instance._state = ModelState() + instance._state.fields_cache[field_name] = value + + +# ============================================================================= +# TestPlatformMappingModel +# ============================================================================= + + +class TestPlatformMappingModel: + """Tests for PlatformMapping model behaviour.""" + + def test_str_representation(self): + """__str__ shows librenms_os -> platform.""" + from netbox_librenms_plugin.models import PlatformMapping + + mapping = PlatformMapping.__new__(PlatformMapping) + mapping.librenms_os = "ios" + platform = MagicMock() + platform.__str__ = lambda s: "Cisco IOS" + _set_fk_cache(mapping, "netbox_platform", platform) + assert str(mapping) == "ios -> Cisco IOS" + + def test_clean_strips_whitespace(self): + """clean() strips leading/trailing whitespace from librenms_os.""" + from netbox_librenms_plugin.models import PlatformMapping + + mapping = PlatformMapping.__new__(PlatformMapping) + mapping.librenms_os = " ios " + mapping.description = "" + with patch("netbox.models.NetBoxModel.clean"): + mapping.clean() + assert mapping.librenms_os == "ios" + + def test_clean_raises_on_blank(self): + """clean() raises ValidationError when librenms_os is blank after strip.""" + from django.core.exceptions import ValidationError + + from netbox_librenms_plugin.models import PlatformMapping + + mapping = PlatformMapping.__new__(PlatformMapping) + mapping.librenms_os = " " + mapping.description = "" + + import pytest + + with pytest.raises(ValidationError) as exc_info: + with patch("netbox.models.NetBoxModel.clean"): + mapping.clean() + assert "librenms_os" in str(exc_info.value) + + def test_get_absolute_url(self): + """get_absolute_url returns correct URL.""" + from netbox_librenms_plugin.models import PlatformMapping + + mapping = PlatformMapping.__new__(PlatformMapping) + mapping.pk = 42 + + with patch("netbox_librenms_plugin.models.reverse") as mock_reverse: + mock_reverse.return_value = "/plugins/librenms/platform-mappings/42/" + url = mapping.get_absolute_url() + mock_reverse.assert_called_once_with("plugins:netbox_librenms_plugin:platformmapping_detail", args=[42]) + assert url == "/plugins/librenms/platform-mappings/42/" + + def test_meta_ordering(self): + """Model Meta ordering is by librenms_os.""" + from netbox_librenms_plugin.models import PlatformMapping + + assert PlatformMapping._meta.ordering == ["librenms_os"] + + +# ============================================================================= +# TestPlatformMappingToYaml +# ============================================================================= + + +class TestPlatformMappingToYaml: + """to_yaml() returns valid YAML string with expected keys.""" + + def test_to_yaml_returns_string(self): + """to_yaml() returns a string.""" + from netbox_librenms_plugin.models import PlatformMapping + + mapping = PlatformMapping.__new__(PlatformMapping) + mapping.librenms_os = "ios" + platform = MagicMock() + platform.__str__ = lambda s: "Cisco IOS" + _set_fk_cache(mapping, "netbox_platform", platform) + mapping.description = "Test description" + + result = mapping.to_yaml() + assert isinstance(result, str) + + def test_to_yaml_contains_expected_keys(self): + """to_yaml() output contains librenms_os, netbox_platform, description.""" + import yaml + + from netbox_librenms_plugin.models import PlatformMapping + + mapping = PlatformMapping.__new__(PlatformMapping) + mapping.librenms_os = "ios" + platform = MagicMock() + platform.__str__ = lambda s: "Cisco IOS" + _set_fk_cache(mapping, "netbox_platform", platform) + mapping.description = "A description" + + result = yaml.safe_load(mapping.to_yaml()) + assert result["librenms_os"] == "ios" + assert result["netbox_platform"] == "Cisco IOS" + assert result["description"] == "A description" + + +# ============================================================================= +# TestToYamlOnAllMappingModels +# ============================================================================= + + +class TestToYamlOnAllMappingModels: + """All mapping models must have to_yaml() returning a YAML string.""" + + def test_device_type_mapping_has_to_yaml(self): + """DeviceTypeMapping.to_yaml() returns a YAML string.""" + import yaml + + from netbox_librenms_plugin.models import DeviceTypeMapping + + mapping = DeviceTypeMapping.__new__(DeviceTypeMapping) + mapping.librenms_hardware = "Cisco 4321" + device_type = MagicMock() + device_type.__str__ = lambda s: "Cisco 4321" + _set_fk_cache(mapping, "netbox_device_type", device_type) + mapping.description = "" + + result = mapping.to_yaml() + assert isinstance(result, str) + data = yaml.safe_load(result) + assert "librenms_hardware" in data + assert data["librenms_hardware"] == "Cisco 4321" + + def test_module_type_mapping_has_to_yaml(self): + """ModuleTypeMapping.to_yaml() returns a YAML string.""" + import yaml + + from netbox_librenms_plugin.models import ModuleTypeMapping + + mapping = ModuleTypeMapping.__new__(ModuleTypeMapping) + mapping.librenms_model = "WS-X4748-RJ45" + module_type = MagicMock() + module_type.__str__ = lambda s: "WS-X4748" + _set_fk_cache(mapping, "netbox_module_type", module_type) + mapping.description = "" + + result = mapping.to_yaml() + assert isinstance(result, str) + data = yaml.safe_load(result) + assert "librenms_model" in data + + def test_interface_type_mapping_has_to_yaml(self): + """InterfaceTypeMapping.to_yaml() returns a YAML string.""" + import yaml + + from netbox_librenms_plugin.models import InterfaceTypeMapping + + mapping = InterfaceTypeMapping.__new__(InterfaceTypeMapping) + mapping.librenms_type = "ether" + mapping.librenms_speed = 1000000 + mapping.netbox_type = "1000base-t" + mapping.description = "" + + result = mapping.to_yaml() + assert isinstance(result, str) + data = yaml.safe_load(result) + assert "librenms_type" in data + + def test_module_bay_mapping_has_to_yaml(self): + """ModuleBayMapping.to_yaml() returns a YAML string.""" + import yaml + + from netbox_librenms_plugin.models import ModuleBayMapping + + mapping = ModuleBayMapping.__new__(ModuleBayMapping) + mapping.librenms_name = "Slot 1" + mapping.librenms_class = "container" + mapping.netbox_bay_name = "Slot 1" + mapping.is_regex = False + mapping.description = "" + + result = mapping.to_yaml() + assert isinstance(result, str) + data = yaml.safe_load(result) + assert "librenms_name" in data + + def test_normalization_rule_has_to_yaml(self): + """NormalizationRule.to_yaml() returns a YAML string.""" + import yaml + + from netbox_librenms_plugin.models import NormalizationRule + + rule = NormalizationRule.__new__(NormalizationRule) + rule.scope = "hardware" + _set_fk_cache(rule, "manufacturer", None) + rule.match_pattern = r"^Cisco\s+" + rule.replacement = "Cisco" + rule.priority = 10 + rule.description = "" + + result = rule.to_yaml() + assert isinstance(result, str) + data = yaml.safe_load(result) + assert "scope" in data + assert "match_pattern" in data + + def test_inventory_ignore_rule_has_to_yaml(self): + """InventoryIgnoreRule.to_yaml() returns a YAML string.""" + import yaml + + from netbox_librenms_plugin.models import InventoryIgnoreRule + + rule = InventoryIgnoreRule.__new__(InventoryIgnoreRule) + rule.name = "Skip IDPROM" + rule.match_type = "ends_with" + rule.pattern = "IDPROM" + rule.action = "skip" + rule.require_serial_match_parent = False + rule.enabled = True + rule.description = "" + + result = rule.to_yaml() + assert isinstance(result, str) + data = yaml.safe_load(result) + assert "name" in data + assert "match_type" in data + + +# ============================================================================= +# TestFindMatchingPlatformWithMapping +# ============================================================================= + + +class TestFindMatchingPlatformWithMapping: + """find_matching_platform checks PlatformMapping before direct name match.""" + + def test_platform_mapping_takes_priority_over_name_match(self): + """When a PlatformMapping exists for the OS, it is returned without querying Platform directly.""" + from netbox_librenms_plugin.utils import find_matching_platform + + mock_mapped_platform = MagicMock(name="mapped_platform") + mock_mapping = MagicMock() + mock_mapping.netbox_platform = mock_mapped_platform + + mock_pm_qs = MagicMock() + mock_pm_qs.get.return_value = mock_mapping + + mock_pm_class = MagicMock() + mock_pm_class.objects = mock_pm_qs + mock_pm_class.DoesNotExist = Exception + + with patch("netbox_librenms_plugin.utils.PlatformMapping", mock_pm_class, create=True): + result = find_matching_platform("ios") + + assert result["found"] is True + assert result["platform"] is mock_mapped_platform + assert result["match_type"] == "mapping" + + def test_falls_back_to_name_match_when_no_platform_mapping(self): + """When no PlatformMapping exists, falls back to exact Platform name match.""" + from netbox_librenms_plugin.utils import find_matching_platform + + mock_platform = MagicMock() + + mock_pm_class = MagicMock() + mock_pm_class.DoesNotExist = type("DoesNotExist", (Exception,), {}) + mock_pm_class.objects.get.side_effect = mock_pm_class.DoesNotExist + + mock_platform_model = MagicMock() + mock_platform_model.objects.get.return_value = mock_platform + + with ( + patch("netbox_librenms_plugin.utils.PlatformMapping", mock_pm_class, create=True), + patch("dcim.models.Platform", mock_platform_model), + ): + result = find_matching_platform("ios") + + assert result["found"] is True + assert result["platform"] is mock_platform + assert result["match_type"] == "exact" + + def test_returns_not_found_when_neither_mapping_nor_platform(self): + """Returns found=False when neither PlatformMapping nor Platform name match exists.""" + from netbox_librenms_plugin.utils import find_matching_platform + + DoesNotExist = type("DoesNotExist", (Exception,), {}) + + mock_pm_class = MagicMock() + mock_pm_class.DoesNotExist = DoesNotExist + mock_pm_class.objects.get.side_effect = DoesNotExist + + mock_platform_model = MagicMock() + mock_platform_model.DoesNotExist = DoesNotExist + mock_platform_model.objects.get.side_effect = DoesNotExist + + with ( + patch("netbox_librenms_plugin.utils.PlatformMapping", mock_pm_class, create=True), + patch("dcim.models.Platform", mock_platform_model), + ): + result = find_matching_platform("unknown_os") + + assert result["found"] is False + assert result["platform"] is None + + +# ============================================================================= +# TestBulkExportYAMLView +# ============================================================================= + + +class TestBulkExportYAMLView: + """BulkExportYAMLView returns YAML for selected PKs.""" + + def _make_request(self, pk_list): + request = MagicMock() + request.POST = MagicMock() + request.POST.getlist = MagicMock(return_value=pk_list) + return request + + def test_returns_yaml_content_type(self): + """Response has content-type text/yaml.""" + + from netbox_librenms_plugin.views.mapping_views import DeviceTypeMappingBulkExportYAMLView + + view = DeviceTypeMappingBulkExportYAMLView.__new__(DeviceTypeMappingBulkExportYAMLView) + request = self._make_request(["1", "2"]) + + mock_mapping = MagicMock() + mock_mapping.to_yaml.return_value = "librenms_hardware: Cisco 4321\n" + + mock_qs = MagicMock() + mock_qs.filter.return_value = [mock_mapping, mock_mapping] + view.queryset = mock_qs + + with patch.object(view, "require_write_permission", return_value=None): + response = view.post(request) + + assert response.status_code == 200 + assert "yaml" in response["Content-Type"] + + def test_returns_yaml_for_selected_pks(self): + """Response body contains YAML from selected objects.""" + from netbox_librenms_plugin.views.mapping_views import DeviceTypeMappingBulkExportYAMLView + + view = DeviceTypeMappingBulkExportYAMLView.__new__(DeviceTypeMappingBulkExportYAMLView) + request = self._make_request(["1"]) + + mock_mapping = MagicMock() + mock_mapping.to_yaml.return_value = "librenms_hardware: Cisco 4321\n" + + mock_qs = MagicMock() + mock_qs.filter.return_value = [mock_mapping] + view.queryset = mock_qs + + with patch.object(view, "require_write_permission", return_value=None): + response = view.post(request) + + content = response.content.decode() + assert "Cisco 4321" in content + + def test_filters_by_selected_pks(self): + """View filters queryset by the selected PKs from POST data.""" + from netbox_librenms_plugin.views.mapping_views import DeviceTypeMappingBulkExportYAMLView + + view = DeviceTypeMappingBulkExportYAMLView.__new__(DeviceTypeMappingBulkExportYAMLView) + request = self._make_request(["3", "7"]) + + mock_qs = MagicMock() + mock_qs.filter.return_value = [] + view.queryset = mock_qs + + with patch.object(view, "require_write_permission", return_value=None): + view.post(request) + + mock_qs.filter.assert_called_once_with(pk__in=["3", "7"]) + + def test_returns_200_with_empty_selection(self): + """Response is 200 even when no PKs are selected (empty YAML).""" + from netbox_librenms_plugin.views.mapping_views import DeviceTypeMappingBulkExportYAMLView + + view = DeviceTypeMappingBulkExportYAMLView.__new__(DeviceTypeMappingBulkExportYAMLView) + request = self._make_request([]) + + mock_qs = MagicMock() + mock_qs.filter.return_value = [] + view.queryset = mock_qs + + with patch.object(view, "require_write_permission", return_value=None): + response = view.post(request) + + assert response.status_code == 200 + + def test_platform_mapping_bulk_export_yaml_view_exists(self): + """PlatformMappingBulkExportYAMLView class exists in mapping_views.""" + from netbox_librenms_plugin.views.mapping_views import PlatformMappingBulkExportYAMLView + + assert PlatformMappingBulkExportYAMLView is not None + + def test_all_mapping_bulk_export_yaml_views_exist(self): + """All mapping model BulkExportYAML views exist.""" + from netbox_librenms_plugin.views.mapping_views import ( + DeviceTypeMappingBulkExportYAMLView, + InterfaceTypeMappingBulkExportYAMLView, + InventoryIgnoreRuleBulkExportYAMLView, + ModuleBayMappingBulkExportYAMLView, + ModuleTypeMappingBulkExportYAMLView, + NormalizationRuleBulkExportYAMLView, + PlatformMappingBulkExportYAMLView, + ) + + for cls in [ + DeviceTypeMappingBulkExportYAMLView, + InterfaceTypeMappingBulkExportYAMLView, + InventoryIgnoreRuleBulkExportYAMLView, + ModuleBayMappingBulkExportYAMLView, + ModuleTypeMappingBulkExportYAMLView, + NormalizationRuleBulkExportYAMLView, + PlatformMappingBulkExportYAMLView, + ]: + assert cls is not None + + +# ============================================================================= +# TestPlatformMappingViewsExist +# ============================================================================= + + +class TestPlatformMappingViewsExist: + """All PlatformMapping CRUD views must exist in mapping_views.""" + + def test_list_view_exists(self): + from netbox_librenms_plugin.views.mapping_views import PlatformMappingListView + + assert PlatformMappingListView is not None + + def test_create_view_exists(self): + from netbox_librenms_plugin.views.mapping_views import PlatformMappingCreateView + + assert PlatformMappingCreateView is not None + + def test_edit_view_exists(self): + from netbox_librenms_plugin.views.mapping_views import PlatformMappingEditView + + assert PlatformMappingEditView is not None + + def test_delete_view_exists(self): + from netbox_librenms_plugin.views.mapping_views import PlatformMappingDeleteView + + assert PlatformMappingDeleteView is not None + + def test_bulk_delete_view_exists(self): + from netbox_librenms_plugin.views.mapping_views import PlatformMappingBulkDeleteView + + assert PlatformMappingBulkDeleteView is not None + + def test_bulk_import_view_exists(self): + from netbox_librenms_plugin.views.mapping_views import PlatformMappingBulkImportView + + assert PlatformMappingBulkImportView is not None + + def test_detail_view_exists(self): + from netbox_librenms_plugin.views.mapping_views import PlatformMappingView + + assert PlatformMappingView is not None + + def test_changelog_view_exists(self): + from netbox_librenms_plugin.views.mapping_views import PlatformMappingChangeLogView + + assert PlatformMappingChangeLogView is not None + + +# ============================================================================= +# TestPlatformMappingFormsExist +# ============================================================================= + + +class TestPlatformMappingFormsExist: + """PlatformMapping form classes must exist.""" + + def test_form_exists(self): + from netbox_librenms_plugin.forms import PlatformMappingForm + + assert PlatformMappingForm is not None + + def test_filter_form_exists(self): + from netbox_librenms_plugin.forms import PlatformMappingFilterForm + + assert PlatformMappingFilterForm is not None + + def test_import_form_exists(self): + from netbox_librenms_plugin.forms import PlatformMappingImportForm + + assert PlatformMappingImportForm is not None + + def test_filter_form_has_librenms_os_field(self): + """PlatformMappingFilterForm has librenms_os as a filter field.""" + from netbox_librenms_plugin.forms import PlatformMappingFilterForm + + assert "librenms_os" in PlatformMappingFilterForm.base_fields + + def test_import_form_fields_cover_required_columns(self): + """PlatformMappingImportForm covers librenms_os and netbox_platform.""" + from netbox_librenms_plugin.forms import PlatformMappingImportForm + + assert "librenms_os" in PlatformMappingImportForm._meta.fields + assert "netbox_platform" in PlatformMappingImportForm._meta.fields diff --git a/netbox_librenms_plugin/tests/test_utils.py b/netbox_librenms_plugin/tests/test_utils.py index 581010f47a..1207764d8f 100644 --- a/netbox_librenms_plugin/tests/test_utils.py +++ b/netbox_librenms_plugin/tests/test_utils.py @@ -213,11 +213,14 @@ def test_find_site_for_location_dash(self): class TestPlatformMatching: """Test platform matching logic.""" + @patch("netbox_librenms_plugin.utils.PlatformMapping") @patch("dcim.models.Platform") - def test_find_platform_for_os_exact_match(self, mock_platform_model): + def test_find_platform_for_os_exact_match(self, mock_platform_model, mock_platform_mapping): """OS string matched to platform.""" mock_platform = MagicMock(id=1, name="ios") mock_platform_model.objects.get.return_value = mock_platform + mock_platform_mapping.DoesNotExist = Exception + mock_platform_mapping.objects.get.side_effect = mock_platform_mapping.DoesNotExist from netbox_librenms_plugin.utils import find_matching_platform @@ -227,9 +230,12 @@ def test_find_platform_for_os_exact_match(self, mock_platform_model): assert result["platform"] == mock_platform assert result["match_type"] == "exact" + @patch("netbox_librenms_plugin.utils.PlatformMapping") @patch("dcim.models.Platform") - def test_find_platform_for_os_not_found(self, mock_platform_model): + def test_find_platform_for_os_not_found(self, mock_platform_model, mock_platform_mapping): """Returns None when no match.""" + mock_platform_mapping.DoesNotExist = Exception + mock_platform_mapping.objects.get.side_effect = mock_platform_mapping.DoesNotExist mock_platform_model.DoesNotExist = Exception mock_platform_model.objects.get.side_effect = mock_platform_model.DoesNotExist diff --git a/netbox_librenms_plugin/urls.py b/netbox_librenms_plugin/urls.py index 6bfa6b98b4..eb51e3d8cd 100644 --- a/netbox_librenms_plugin/urls.py +++ b/netbox_librenms_plugin/urls.py @@ -7,6 +7,7 @@ ModuleBayMapping, ModuleTypeMapping, NormalizationRule, + PlatformMapping, ) from .views import ( AddDeviceToLibreNMSView, @@ -85,6 +86,21 @@ InventoryIgnoreRuleEditView, InventoryIgnoreRuleListView, InventoryIgnoreRuleView, + DeviceTypeMappingBulkExportYAMLView, + InterfaceTypeMappingBulkExportYAMLView, + ModuleBayMappingBulkExportYAMLView, + ModuleTypeMappingBulkExportYAMLView, + NormalizationRuleBulkExportYAMLView, + InventoryIgnoreRuleBulkExportYAMLView, + PlatformMappingBulkDeleteView, + PlatformMappingBulkExportYAMLView, + PlatformMappingBulkImportView, + PlatformMappingChangeLogView, + PlatformMappingCreateView, + PlatformMappingDeleteView, + PlatformMappingEditView, + PlatformMappingListView, + PlatformMappingView, RemoveServerMappingView, SaveUserPrefView, SingleCableVerifyView, @@ -652,5 +668,83 @@ InventoryIgnoreRuleBulkDeleteView.as_view(), name="inventoryignorerule_bulk_delete", ), + # Bulk YAML export URLs for all mapping models + path( + "interface-type-mappings/export-yaml/", + InterfaceTypeMappingBulkExportYAMLView.as_view(), + name="interfacetypemapping_bulk_export_yaml", + ), + path( + "device-type-mappings/export-yaml/", + DeviceTypeMappingBulkExportYAMLView.as_view(), + name="devicetypemapping_bulk_export_yaml", + ), + path( + "module-type-mappings/export-yaml/", + ModuleTypeMappingBulkExportYAMLView.as_view(), + name="moduletypemapping_bulk_export_yaml", + ), + path( + "module-bay-mappings/export-yaml/", + ModuleBayMappingBulkExportYAMLView.as_view(), + name="modulebaymapping_bulk_export_yaml", + ), + path( + "normalization-rules/export-yaml/", + NormalizationRuleBulkExportYAMLView.as_view(), + name="normalizationrule_bulk_export_yaml", + ), + path( + "inventory-ignore-rules/export-yaml/", + InventoryIgnoreRuleBulkExportYAMLView.as_view(), + name="inventoryignorerule_bulk_export_yaml", + ), + # Platform Mapping URLs + path( + "platform-mappings/", + PlatformMappingListView.as_view(), + name="platformmapping_list", + ), + path( + "platform-mappings//", + PlatformMappingView.as_view(), + name="platformmapping_detail", + ), + path( + "platform-mappings/add/", + PlatformMappingCreateView.as_view(), + name="platformmapping_add", + ), + path( + "platform-mappings/import/", + PlatformMappingBulkImportView.as_view(), + name="platformmapping_bulk_import", + ), + path( + "platform-mappings//delete/", + PlatformMappingDeleteView.as_view(), + name="platformmapping_delete", + ), + path( + "platform-mappings//edit/", + PlatformMappingEditView.as_view(), + name="platformmapping_edit", + ), + path( + "platform-mappings//changelog/", + PlatformMappingChangeLogView.as_view(), + name="platformmapping_changelog", + kwargs={"model": PlatformMapping}, + ), + path( + "platform-mappings/delete/", + PlatformMappingBulkDeleteView.as_view(), + name="platformmapping_bulk_delete", + ), + path( + "platform-mappings/export-yaml/", + PlatformMappingBulkExportYAMLView.as_view(), + name="platformmapping_bulk_export_yaml", + ), path("api/", include("netbox_librenms_plugin.api.urls")), ] diff --git a/netbox_librenms_plugin/utils.py b/netbox_librenms_plugin/utils.py index c463040028..665a5f1997 100644 --- a/netbox_librenms_plugin/utils.py +++ b/netbox_librenms_plugin/utils.py @@ -3,8 +3,8 @@ from typing import Optional from dcim.models import Device -from django.db.models import Q from django.core.exceptions import ObjectDoesNotExist +from django.db.models import Q from django.http import HttpRequest from netbox.config import get_config from netbox.plugins import get_plugin_config @@ -13,6 +13,12 @@ logger = logging.getLogger(__name__) +try: + from netbox_librenms_plugin.models import PlatformMapping +except ImportError: + PlatformMapping = None # type: ignore[assignment] + + def convert_speed_to_kbps(speed_bps: int) -> int | None: """ Convert speed from bits per second to kilobits per second. @@ -338,9 +344,10 @@ def find_matching_site(librenms_location: str) -> dict: def find_matching_platform(librenms_os: str) -> dict: """ - Find exact matching NetBox platform for a LibreNMS OS. + Find matching NetBox platform for a LibreNMS OS. - Only performs exact name matching (case-insensitive). + Checks PlatformMapping table first (explicit user-defined mapping), + then falls back to exact case-insensitive name match. Args: librenms_os (str): OS string from LibreNMS (e.g., 'ios', 'linux', 'junos') @@ -349,13 +356,21 @@ def find_matching_platform(librenms_os: str) -> dict: dict: Dictionary containing: - found (bool): Whether a match was found - platform (Platform|None): The matched Platform object - - match_type (str|None): Always 'exact' if found, None otherwise + - match_type (str|None): 'mapping', 'exact', or None """ from dcim.models import Platform if not librenms_os or librenms_os == "-": return {"found": False, "platform": None, "match_type": None} + # Check PlatformMapping table first + if PlatformMapping is not None: + try: + mapping = PlatformMapping.objects.get(librenms_os__iexact=librenms_os) + return {"found": True, "platform": mapping.netbox_platform, "match_type": "mapping"} + except PlatformMapping.DoesNotExist: + pass + # Try case-insensitive exact name match try: platform = Platform.objects.get(name__iexact=librenms_os) diff --git a/netbox_librenms_plugin/views/__init__.py b/netbox_librenms_plugin/views/__init__.py index 23ee0820cd..bda0abffca 100644 --- a/netbox_librenms_plugin/views/__init__.py +++ b/netbox_librenms_plugin/views/__init__.py @@ -81,6 +81,21 @@ InventoryIgnoreRuleEditView, InventoryIgnoreRuleListView, InventoryIgnoreRuleView, + DeviceTypeMappingBulkExportYAMLView, + InterfaceTypeMappingBulkExportYAMLView, + ModuleBayMappingBulkExportYAMLView, + ModuleTypeMappingBulkExportYAMLView, + NormalizationRuleBulkExportYAMLView, + InventoryIgnoreRuleBulkExportYAMLView, + PlatformMappingBulkDeleteView, + PlatformMappingBulkExportYAMLView, + PlatformMappingBulkImportView, + PlatformMappingChangeLogView, + PlatformMappingCreateView, + PlatformMappingDeleteView, + PlatformMappingEditView, + PlatformMappingListView, + PlatformMappingView, ) from .object_sync import ( # noqa: F401 DeviceCableTableView, diff --git a/netbox_librenms_plugin/views/mapping_views.py b/netbox_librenms_plugin/views/mapping_views.py index 5be55fce39..9e7b5e4e13 100644 --- a/netbox_librenms_plugin/views/mapping_views.py +++ b/netbox_librenms_plugin/views/mapping_views.py @@ -1,3 +1,4 @@ +from django.http import HttpResponse from netbox.views import generic from utilities.views import register_model_view @@ -8,6 +9,7 @@ ModuleBayMappingFilterSet, ModuleTypeMappingFilterSet, NormalizationRuleFilterSet, + PlatformMappingFilterSet, ) from netbox_librenms_plugin.forms import ( DeviceTypeMappingFilterForm, @@ -28,6 +30,9 @@ NormalizationRuleFilterForm, NormalizationRuleForm, NormalizationRuleImportForm, + PlatformMappingFilterForm, + PlatformMappingForm, + PlatformMappingImportForm, ) from netbox_librenms_plugin.models import ( DeviceTypeMapping, @@ -36,6 +41,7 @@ ModuleBayMapping, ModuleTypeMapping, NormalizationRule, + PlatformMapping, ) from netbox_librenms_plugin.tables.mappings import ( DeviceTypeMappingTable, @@ -44,6 +50,7 @@ ModuleBayMappingTable, ModuleTypeMappingTable, NormalizationRuleTable, + PlatformMappingTable, ) from netbox_librenms_plugin.views.mixins import LibreNMSPermissionMixin @@ -420,3 +427,111 @@ class InventoryIgnoreRuleChangeLogView(LibreNMSPermissionMixin, generic.ObjectCh """Provides a view for displaying the change log of a specific InventoryIgnoreRule object.""" queryset = InventoryIgnoreRule.objects.all() + + +# --- BulkExportYAML views --- + + +class BulkExportYAMLView(LibreNMSPermissionMixin): + """Base view that exports selected mapping objects as YAML.""" + + queryset = None + + def post(self, request): + if error := self.require_write_permission(request): + return error + pks = request.POST.getlist("pk") + objects = self.queryset.filter(pk__in=pks) + yaml_parts = [obj.to_yaml() for obj in objects] + content = "---\n".join(yaml_parts) + response = HttpResponse(content, content_type="text/yaml; charset=utf-8") + response["Content-Disposition"] = 'attachment; filename="export.yaml"' + return response + + +class InterfaceTypeMappingBulkExportYAMLView(BulkExportYAMLView): + queryset = InterfaceTypeMapping.objects.all() + + +class DeviceTypeMappingBulkExportYAMLView(BulkExportYAMLView): + queryset = DeviceTypeMapping.objects.all() + + +class ModuleTypeMappingBulkExportYAMLView(BulkExportYAMLView): + queryset = ModuleTypeMapping.objects.all() + + +class ModuleBayMappingBulkExportYAMLView(BulkExportYAMLView): + queryset = ModuleBayMapping.objects.all() + + +class NormalizationRuleBulkExportYAMLView(BulkExportYAMLView): + queryset = NormalizationRule.objects.all() + + +class InventoryIgnoreRuleBulkExportYAMLView(BulkExportYAMLView): + queryset = InventoryIgnoreRule.objects.all() + + +class PlatformMappingBulkExportYAMLView(BulkExportYAMLView): + queryset = PlatformMapping.objects.all() + + +# --- PlatformMapping views --- + + +class PlatformMappingListView(LibreNMSPermissionMixin, generic.ObjectListView): + """Provides a view for listing all PlatformMapping objects.""" + + queryset = PlatformMapping.objects.all() + table = PlatformMappingTable + filterset = PlatformMappingFilterSet + filterset_form = PlatformMappingFilterForm + template_name = "netbox_librenms_plugin/platformmapping_list.html" + + +class PlatformMappingCreateView(LibreNMSPermissionMixin, generic.ObjectEditView): + """Provides a view for creating a new PlatformMapping object.""" + + queryset = PlatformMapping.objects.all() + form = PlatformMappingForm + + +@register_model_view(PlatformMapping, "bulk_import", path="import", detail=False) +class PlatformMappingBulkImportView(LibreNMSPermissionMixin, generic.BulkImportView): + """Provides a view for bulk importing PlatformMapping objects.""" + + queryset = PlatformMapping.objects.all() + model_form = PlatformMappingImportForm + + +class PlatformMappingView(LibreNMSPermissionMixin, generic.ObjectView): + """Provides a view for displaying details of a specific PlatformMapping object.""" + + queryset = PlatformMapping.objects.all() + + +class PlatformMappingEditView(LibreNMSPermissionMixin, generic.ObjectEditView): + """Provides a view for editing a specific PlatformMapping object.""" + + queryset = PlatformMapping.objects.all() + form = PlatformMappingForm + + +class PlatformMappingDeleteView(LibreNMSPermissionMixin, generic.ObjectDeleteView): + """Provides a view for deleting a specific PlatformMapping object.""" + + queryset = PlatformMapping.objects.all() + + +class PlatformMappingBulkDeleteView(LibreNMSPermissionMixin, generic.BulkDeleteView): + """Provides a view for deleting multiple PlatformMapping objects.""" + + queryset = PlatformMapping.objects.all() + table = PlatformMappingTable + + +class PlatformMappingChangeLogView(LibreNMSPermissionMixin, generic.ObjectChangeLogView): + """Provides a view for displaying the change log of a specific PlatformMapping object.""" + + queryset = PlatformMapping.objects.all() From 6750dd8a6c33c81748213837a49c22cf90332cd9 Mon Sep 17 00:00:00 2001 From: Marcin Zieba Date: Tue, 31 Mar 2026 09:54:21 +0200 Subject: [PATCH 66/71] fix: apply PR review findings - api/views.py: derive _LIBRENMS_JOB_NAMES from FilterDevicesJob.Meta.name and ImportDevicesJob.Meta.name instead of hardcoded strings - utils.py (find_matching_platform): catch PlatformMapping.MultipleObjectsReturned as a separate except clause alongside DoesNotExist, falling through to next lookup - utils.py (get_librenms_device_id): reject non-positive parsed int from string branch; add value <= 0 guard before auto_save and return - tables/mappings.py: add 'manufacturer' to NormalizationRuleTable.default_columns - tables/__init__.py: export all 7 mapping table classes and add them to __all__ - views/mapping_views.py: inherit BulkExportYAMLView from View so as_view() exists; fix require_write_permission() call (no args) --- netbox_librenms_plugin/api/views.py | 3 ++- netbox_librenms_plugin/tables/__init__.py | 16 +++++++++++++++- netbox_librenms_plugin/tables/mappings.py | 1 + netbox_librenms_plugin/utils.py | 4 ++++ netbox_librenms_plugin/views/mapping_views.py | 5 +++-- 5 files changed, 25 insertions(+), 4 deletions(-) diff --git a/netbox_librenms_plugin/api/views.py b/netbox_librenms_plugin/api/views.py index 946070f134..b178d13373 100644 --- a/netbox_librenms_plugin/api/views.py +++ b/netbox_librenms_plugin/api/views.py @@ -21,6 +21,7 @@ NormalizationRuleFilterSet, PlatformMappingFilterSet, ) +from netbox_librenms_plugin.jobs import FilterDevicesJob, ImportDevicesJob from netbox_librenms_plugin.models import ( DeviceTypeMapping, InterfaceTypeMapping, @@ -146,7 +147,7 @@ def sync_job_status(request, job_pk): Returns: JsonResponse with updated status """ - _LIBRENMS_JOB_NAMES = ("LibreNMS Device Filter", "LibreNMS Device Import") + _LIBRENMS_JOB_NAMES = (FilterDevicesJob.Meta.name, ImportDevicesJob.Meta.name) try: job = Job.objects.get(pk=job_pk, user=request.user, name__in=_LIBRENMS_JOB_NAMES) except Job.DoesNotExist: diff --git a/netbox_librenms_plugin/tables/__init__.py b/netbox_librenms_plugin/tables/__init__.py index 32bade63f0..8fb6f46918 100644 --- a/netbox_librenms_plugin/tables/__init__.py +++ b/netbox_librenms_plugin/tables/__init__.py @@ -3,18 +3,32 @@ from .interfaces import LibreNMSInterfaceTable, LibreNMSVMInterfaceTable, VCInterfaceTable from .ipaddresses import IPAddressTable from .locations import SiteLocationSyncTable -from .mappings import InterfaceTypeMappingTable +from .mappings import ( + DeviceTypeMappingTable, + InterfaceTypeMappingTable, + InventoryIgnoreRuleTable, + ModuleBayMappingTable, + ModuleTypeMappingTable, + NormalizationRuleTable, + PlatformMappingTable, +) from .vlans import LibreNMSVLANTable from .VM_status import VMStatusTable __all__ = [ "DeviceStatusTable", + "DeviceTypeMappingTable", "InterfaceTypeMappingTable", + "InventoryIgnoreRuleTable", "IPAddressTable", "LibreNMSCableTable", "LibreNMSInterfaceTable", "LibreNMSVLANTable", "LibreNMSVMInterfaceTable", + "ModuleBayMappingTable", + "ModuleTypeMappingTable", + "NormalizationRuleTable", + "PlatformMappingTable", "SiteLocationSyncTable", "VCInterfaceTable", "VMStatusTable", diff --git a/netbox_librenms_plugin/tables/mappings.py b/netbox_librenms_plugin/tables/mappings.py index a24c591858..cb9e4f6803 100644 --- a/netbox_librenms_plugin/tables/mappings.py +++ b/netbox_librenms_plugin/tables/mappings.py @@ -168,6 +168,7 @@ class Meta: default_columns = ( "id", "scope", + "manufacturer", "match_pattern", "replacement", "priority", diff --git a/netbox_librenms_plugin/utils.py b/netbox_librenms_plugin/utils.py index 665a5f1997..1461bbaa45 100644 --- a/netbox_librenms_plugin/utils.py +++ b/netbox_librenms_plugin/utils.py @@ -370,6 +370,8 @@ def find_matching_platform(librenms_os: str) -> dict: return {"found": True, "platform": mapping.netbox_platform, "match_type": "mapping"} except PlatformMapping.DoesNotExist: pass + except PlatformMapping.MultipleObjectsReturned: + pass # Try case-insensitive exact name match try: @@ -580,6 +582,8 @@ def get_librenms_device_id(obj, server_key: str = "default", *, auto_save: bool value = int(value) except (ValueError, TypeError): return None + if value <= 0: + return None if auto_save: cf_value[server_key] = value obj.custom_field_data["librenms_id"] = cf_value diff --git a/netbox_librenms_plugin/views/mapping_views.py b/netbox_librenms_plugin/views/mapping_views.py index 9e7b5e4e13..f2a01174de 100644 --- a/netbox_librenms_plugin/views/mapping_views.py +++ b/netbox_librenms_plugin/views/mapping_views.py @@ -1,4 +1,5 @@ from django.http import HttpResponse +from django.views import View from netbox.views import generic from utilities.views import register_model_view @@ -432,13 +433,13 @@ class InventoryIgnoreRuleChangeLogView(LibreNMSPermissionMixin, generic.ObjectCh # --- BulkExportYAML views --- -class BulkExportYAMLView(LibreNMSPermissionMixin): +class BulkExportYAMLView(LibreNMSPermissionMixin, View): """Base view that exports selected mapping objects as YAML.""" queryset = None def post(self, request): - if error := self.require_write_permission(request): + if error := self.require_write_permission(): return error pks = request.POST.getlist("pk") objects = self.queryset.filter(pk__in=pks) From f2981664009196afea2045bce1b6da288cc5d71a Mon Sep 17 00:00:00 2001 From: Marcin Zieba Date: Tue, 31 Mar 2026 11:03:47 +0200 Subject: [PATCH 67/71] fix: select_related on mapping views, pk validation, ambiguous platform mapping - Add select_related to PlatformMappingViewSet and all mapping list views to prevent N+1 queries on FK columns with linkify=True - Validate/convert PKs to int in BulkExportYAMLView, return 400 on invalid - Return ambiguous_mapping result on PlatformMapping.MultipleObjectsReturned instead of silently falling through to exact Platform lookup --- netbox_librenms_plugin/api/views.py | 2 +- .../tests/test_platform_mapping.py | 2 +- netbox_librenms_plugin/utils.py | 2 +- netbox_librenms_plugin/views/mapping_views.py | 16 ++++++++++------ 4 files changed, 13 insertions(+), 9 deletions(-) diff --git a/netbox_librenms_plugin/api/views.py b/netbox_librenms_plugin/api/views.py index b178d13373..60f8178590 100644 --- a/netbox_librenms_plugin/api/views.py +++ b/netbox_librenms_plugin/api/views.py @@ -125,7 +125,7 @@ class PlatformMappingViewSet(NetBoxModelViewSet): permission_classes = [LibreNMSPluginPermission] filterset_class = PlatformMappingFilterSet - queryset = PlatformMapping.objects.all() + queryset = PlatformMapping.objects.select_related("netbox_platform") serializer_class = PlatformMappingSerializer diff --git a/netbox_librenms_plugin/tests/test_platform_mapping.py b/netbox_librenms_plugin/tests/test_platform_mapping.py index cd2d2987f3..2e2bb3daba 100644 --- a/netbox_librenms_plugin/tests/test_platform_mapping.py +++ b/netbox_librenms_plugin/tests/test_platform_mapping.py @@ -393,7 +393,7 @@ def test_filters_by_selected_pks(self): with patch.object(view, "require_write_permission", return_value=None): view.post(request) - mock_qs.filter.assert_called_once_with(pk__in=["3", "7"]) + mock_qs.filter.assert_called_once_with(pk__in=[3, 7]) def test_returns_200_with_empty_selection(self): """Response is 200 even when no PKs are selected (empty YAML).""" diff --git a/netbox_librenms_plugin/utils.py b/netbox_librenms_plugin/utils.py index 1461bbaa45..c4a2f18751 100644 --- a/netbox_librenms_plugin/utils.py +++ b/netbox_librenms_plugin/utils.py @@ -371,7 +371,7 @@ def find_matching_platform(librenms_os: str) -> dict: except PlatformMapping.DoesNotExist: pass except PlatformMapping.MultipleObjectsReturned: - pass + return {"found": True, "platform": None, "match_type": "ambiguous_mapping"} # Try case-insensitive exact name match try: diff --git a/netbox_librenms_plugin/views/mapping_views.py b/netbox_librenms_plugin/views/mapping_views.py index f2a01174de..1baf1ce17a 100644 --- a/netbox_librenms_plugin/views/mapping_views.py +++ b/netbox_librenms_plugin/views/mapping_views.py @@ -1,4 +1,4 @@ -from django.http import HttpResponse +from django.http import HttpResponse, HttpResponseBadRequest from django.views import View from netbox.views import generic from utilities.views import register_model_view @@ -136,7 +136,7 @@ class InterfaceTypeMappingChangeLogView(LibreNMSPermissionMixin, generic.ObjectC class DeviceTypeMappingListView(LibreNMSPermissionMixin, generic.ObjectListView): """Provides a view for listing all DeviceTypeMapping objects.""" - queryset = DeviceTypeMapping.objects.all() + queryset = DeviceTypeMapping.objects.select_related("netbox_device_type") table = DeviceTypeMappingTable filterset = DeviceTypeMappingFilterSet filterset_form = DeviceTypeMappingFilterForm @@ -196,7 +196,7 @@ class DeviceTypeMappingChangeLogView(LibreNMSPermissionMixin, generic.ObjectChan class ModuleTypeMappingListView(LibreNMSPermissionMixin, generic.ObjectListView): """Provides a view for listing all ModuleTypeMapping objects.""" - queryset = ModuleTypeMapping.objects.all() + queryset = ModuleTypeMapping.objects.select_related("netbox_module_type") table = ModuleTypeMappingTable filterset = ModuleTypeMappingFilterSet filterset_form = ModuleTypeMappingFilterForm @@ -316,7 +316,7 @@ class ModuleBayMappingChangeLogView(LibreNMSPermissionMixin, generic.ObjectChang class NormalizationRuleListView(LibreNMSPermissionMixin, generic.ObjectListView): """Provides a view for listing all NormalizationRule objects.""" - queryset = NormalizationRule.objects.all() + queryset = NormalizationRule.objects.select_related("manufacturer") table = NormalizationRuleTable filterset = NormalizationRuleFilterSet filterset_form = NormalizationRuleFilterForm @@ -442,7 +442,11 @@ def post(self, request): if error := self.require_write_permission(): return error pks = request.POST.getlist("pk") - objects = self.queryset.filter(pk__in=pks) + try: + int_pks = [int(pk) for pk in pks] + except (ValueError, TypeError): + return HttpResponseBadRequest("Invalid pk value.") + objects = self.queryset.filter(pk__in=int_pks) yaml_parts = [obj.to_yaml() for obj in objects] content = "---\n".join(yaml_parts) response = HttpResponse(content, content_type="text/yaml; charset=utf-8") @@ -484,7 +488,7 @@ class PlatformMappingBulkExportYAMLView(BulkExportYAMLView): class PlatformMappingListView(LibreNMSPermissionMixin, generic.ObjectListView): """Provides a view for listing all PlatformMapping objects.""" - queryset = PlatformMapping.objects.all() + queryset = PlatformMapping.objects.select_related("netbox_platform") table = PlatformMappingTable filterset = PlatformMappingFilterSet filterset_form = PlatformMappingFilterForm From 264174c03066f35f568245e2a8b3f533d43483ce Mon Sep 17 00:00:00 2001 From: Marcin Zieba Date: Tue, 31 Mar 2026 12:15:20 +0200 Subject: [PATCH 68/71] fix: platform ambiguous mapping falls through; string-digit librenms_id matches numeric JSON - find_matching_platform: MultipleObjectsReturned now passes through to the next matching path (Platform name match) instead of returning found=True with a None platform, which callers could mistakenly treat as a successful hit - find_by_librenms_id: when librenms_id is a digit string (e.g. "42"), also add Q clauses for int(librenms_id) so records that store the value as a JSON number are matched alongside string-form records --- netbox_librenms_plugin/utils.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/netbox_librenms_plugin/utils.py b/netbox_librenms_plugin/utils.py index c4a2f18751..9654883504 100644 --- a/netbox_librenms_plugin/utils.py +++ b/netbox_librenms_plugin/utils.py @@ -371,7 +371,7 @@ def find_matching_platform(librenms_os: str) -> dict: except PlatformMapping.DoesNotExist: pass except PlatformMapping.MultipleObjectsReturned: - return {"found": True, "platform": None, "match_type": "ambiguous_mapping"} + pass # Try case-insensitive exact name match try: @@ -691,6 +691,12 @@ def find_by_librenms_id(model, librenms_id, server_key: str = "default"): # regardless of which server is currently active. q |= Q(custom_field_data__librenms_id=librenms_id) q |= Q(custom_field_data__librenms_id=str(librenms_id)) + # When a string ID looks like an integer, also match the numeric JSON form so + # "42" matches records that store the value as the JSON number 42. + if isinstance(librenms_id, str) and librenms_id.isdigit(): + int_value = int(librenms_id) + q |= Q(**{f"custom_field_data__librenms_id__{server_key}": int_value}) + q |= Q(custom_field_data__librenms_id=int_value) return model.objects.filter(q).first() From b610277e522dbc3de4e6070777f9b0601ead3999 Mon Sep 17 00:00:00 2001 From: Marcin Zieba Date: Tue, 31 Mar 2026 13:20:00 +0200 Subject: [PATCH 69/71] fix: treat falsey librenms_id (e.g. 0) as unset in set_librenms_device_id LibreNMS IDs start at 1, so a stored 0 is invalid. Using `or {}` instead of `if cf_value is None` prevents the legacy bare-integer guard from permanently blocking writes on devices with a falsey stored value. --- netbox_librenms_plugin/utils.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/netbox_librenms_plugin/utils.py b/netbox_librenms_plugin/utils.py index 9654883504..e508dab671 100644 --- a/netbox_librenms_plugin/utils.py +++ b/netbox_librenms_plugin/utils.py @@ -615,9 +615,7 @@ def set_librenms_device_id(obj, device_id, server_key: str = "default"): obj, ) return - cf_value = obj.custom_field_data.get("librenms_id") - if cf_value is None: - cf_value = {} + cf_value = obj.custom_field_data.get("librenms_id") or {} if isinstance(cf_value, int) and not isinstance(cf_value, bool): logger.warning( "librenms_id on %r has legacy bare integer %r; skipping write to prevent " From 9f2ee6321e9ed2b3abbcf9b28eb68c758f02f02c Mon Sep 17 00:00:00 2001 From: Marcin Zieba Date: Tue, 31 Mar 2026 19:41:07 +0200 Subject: [PATCH 70/71] fix: ambiguous PlatformMapping fails closed instead of falling through When multiple PlatformMappings match the same OS, return found=False with match_type="ambiguous" so callers warn instead of silently picking a platform from the name-based fallback that may differ from the mapping intent. --- netbox_librenms_plugin/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox_librenms_plugin/utils.py b/netbox_librenms_plugin/utils.py index e508dab671..bc98ebb110 100644 --- a/netbox_librenms_plugin/utils.py +++ b/netbox_librenms_plugin/utils.py @@ -371,7 +371,7 @@ def find_matching_platform(librenms_os: str) -> dict: except PlatformMapping.DoesNotExist: pass except PlatformMapping.MultipleObjectsReturned: - pass + return {"found": False, "platform": None, "match_type": "ambiguous"} # Try case-insensitive exact name match try: From a2243b290d9f6014563be372498004581dc66c06 Mon Sep 17 00:00:00 2001 From: Marcin Zieba Date: Tue, 31 Mar 2026 19:54:36 +0200 Subject: [PATCH 71/71] refactor: squash migrations 0010-0013 into single 0010_inventory_models Merge all inventory model migrations into one: DeviceTypeMapping, ModuleTypeMapping, ModuleBayMapping, NormalizationRule, InventoryIgnoreRule, PlatformMapping with final field states (renamed related_names, SET_NULL on manufacturer, db_index, UniqueConstraints) applied directly. --- .../migrations/0010_inventory_models.py | 117 +++++++++++++----- .../migrations/0011_rename_related_names.py | 63 ---------- ...ormalization_rule_manufacturer_set_null.py | 25 ---- .../migrations/0013_platform_mapping.py | 50 -------- 4 files changed, 89 insertions(+), 166 deletions(-) delete mode 100644 netbox_librenms_plugin/migrations/0011_rename_related_names.py delete mode 100644 netbox_librenms_plugin/migrations/0012_normalization_rule_manufacturer_set_null.py delete mode 100644 netbox_librenms_plugin/migrations/0013_platform_mapping.py diff --git a/netbox_librenms_plugin/migrations/0010_inventory_models.py b/netbox_librenms_plugin/migrations/0010_inventory_models.py index 7d287e38b3..7e0b424122 100644 --- a/netbox_librenms_plugin/migrations/0010_inventory_models.py +++ b/netbox_librenms_plugin/migrations/0010_inventory_models.py @@ -1,11 +1,14 @@ """ Add inventory/modules sync models: DeviceTypeMapping, ModuleTypeMapping, -ModuleBayMapping, NormalizationRule, and InventoryIgnoreRule, along with -two default InventoryIgnoreRule entries. +ModuleBayMapping, NormalizationRule, InventoryIgnoreRule, and PlatformMapping, +along with two default InventoryIgnoreRule entries. + +Squashed from 0010–0013. """ import django.db.models.deletion import netbox.models.deletion +import netbox_librenms_plugin.models import taggit.managers import utilities.json from django.db import migrations, models @@ -50,8 +53,6 @@ def _insert_default_rules(apps, schema_editor): def _delete_default_rules(apps, schema_editor): db_alias = schema_editor.connection.alias InventoryIgnoreRule = apps.get_model("netbox_librenms_plugin", "InventoryIgnoreRule") - # Delete only the exact seeded defaults — match on ALL seeded fields so - # admin-created rules that happen to share a name/pattern are not accidentally removed. InventoryIgnoreRule.objects.using(db_alias).filter( name="Cisco IOS-XR IDPROM entries", match_type="ends_with", @@ -72,8 +73,8 @@ def _delete_default_rules(apps, schema_editor): class Migration(migrations.Migration): dependencies = [ - ("dcim", "0001_initial"), - ("extras", "0001_initial"), + ("dcim", "0225_gfk_indexes"), + ("extras", "0134_owner"), ("netbox_librenms_plugin", "0009_convert_librenms_id_to_json"), ] @@ -95,7 +96,7 @@ class Migration(migrations.Migration): "netbox_device_type", models.ForeignKey( on_delete=django.db.models.deletion.CASCADE, - related_name="librenms_mappings", + related_name="librenms_device_type_mappings", to="dcim.devicetype", ), ), @@ -104,13 +105,27 @@ class Migration(migrations.Migration): options={ "ordering": ["librenms_hardware"], }, - bases=(netbox.models.deletion.DeleteMixin, models.Model), + bases=( + netbox_librenms_plugin.models.FullCleanOnSaveMixin, + netbox.models.deletion.DeleteMixin, + models.Model, + ), ), - # InterfaceTypeMapping ordering + # InterfaceTypeMapping: ordering + unique_together → UniqueConstraint migrations.AlterModelOptions( name="interfacetypemapping", options={"ordering": ["librenms_type", "librenms_speed"]}, ), + migrations.AlterUniqueTogether( + name="interfacetypemapping", + unique_together=set(), + ), + migrations.AddConstraint( + model_name="interfacetypemapping", + constraint=models.UniqueConstraint( + fields=("librenms_type", "librenms_speed"), name="unique_interface_type_mapping" + ), + ), # ModuleTypeMapping migrations.CreateModel( name="ModuleTypeMapping", @@ -128,7 +143,7 @@ class Migration(migrations.Migration): "netbox_module_type", models.ForeignKey( on_delete=django.db.models.deletion.CASCADE, - related_name="librenms_mappings", + related_name="librenms_module_type_mappings", to="dcim.moduletype", ), ), @@ -137,9 +152,13 @@ class Migration(migrations.Migration): options={ "ordering": ["librenms_model"], }, - bases=(netbox.models.deletion.DeleteMixin, models.Model), + bases=( + netbox_librenms_plugin.models.FullCleanOnSaveMixin, + netbox.models.deletion.DeleteMixin, + models.Model, + ), ), - # ModuleBayMapping (with is_regex included) + # ModuleBayMapping (with UniqueConstraint directly) migrations.CreateModel( name="ModuleBayMapping", fields=[ @@ -165,11 +184,20 @@ class Migration(migrations.Migration): ], options={ "ordering": ["librenms_name"], - "unique_together": {("librenms_name", "librenms_class")}, }, - bases=(netbox.models.deletion.DeleteMixin, models.Model), + bases=( + netbox_librenms_plugin.models.FullCleanOnSaveMixin, + netbox.models.deletion.DeleteMixin, + models.Model, + ), ), - # NormalizationRule + migrations.AddConstraint( + model_name="modulebaymapping", + constraint=models.UniqueConstraint( + fields=("librenms_name", "librenms_class"), name="unique_module_bay_mapping" + ), + ), + # NormalizationRule (with SET_NULL and db_index on scope) migrations.CreateModel( name="NormalizationRule", fields=[ @@ -182,14 +210,7 @@ class Migration(migrations.Migration): ), ( "scope", - models.CharField( - choices=[ - ("module_type", "Module Type"), - ("device_type", "Device Type"), - ("module_bay", "Module Bay"), - ], - max_length=50, - ), + models.CharField(db_index=True, max_length=50), ), ("match_pattern", models.CharField(max_length=500)), ("replacement", models.CharField(max_length=500)), @@ -200,7 +221,7 @@ class Migration(migrations.Migration): models.ForeignKey( blank=True, null=True, - on_delete=django.db.models.deletion.CASCADE, + on_delete=django.db.models.deletion.SET_NULL, related_name="normalization_rules", to="dcim.manufacturer", ), @@ -210,9 +231,13 @@ class Migration(migrations.Migration): options={ "ordering": ["scope", "priority", "pk"], }, - bases=(netbox.models.deletion.DeleteMixin, models.Model), + bases=( + netbox_librenms_plugin.models.FullCleanOnSaveMixin, + netbox.models.deletion.DeleteMixin, + models.Model, + ), ), - # InventoryIgnoreRule + # InventoryIgnoreRule (with db_index on enabled) migrations.CreateModel( name="InventoryIgnoreRule", fields=[ @@ -251,14 +276,50 @@ class Migration(migrations.Migration): ), ), ("require_serial_match_parent", models.BooleanField(default=True)), - ("enabled", models.BooleanField(default=True)), + ("enabled", models.BooleanField(db_index=True, default=True)), ("description", models.TextField(blank=True)), ("tags", taggit.managers.TaggableManager(through="extras.TaggedItem", to="extras.Tag")), ], options={ "ordering": ["name", "pk"], }, - bases=(netbox.models.deletion.DeleteMixin, models.Model), + bases=( + netbox_librenms_plugin.models.FullCleanOnSaveMixin, + netbox.models.deletion.DeleteMixin, + models.Model, + ), ), migrations.RunPython(code=_insert_default_rules, reverse_code=_delete_default_rules), + # PlatformMapping + migrations.CreateModel( + name="PlatformMapping", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ("created", models.DateTimeField(auto_now_add=True, null=True)), + ("last_updated", models.DateTimeField(auto_now=True, null=True)), + ( + "custom_field_data", + models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder), + ), + ("librenms_os", models.CharField(max_length=255, unique=True)), + ("description", models.TextField(blank=True)), + ( + "netbox_platform", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="librenms_platform_mappings", + to="dcim.platform", + ), + ), + ("tags", taggit.managers.TaggableManager(through="extras.TaggedItem", to="extras.Tag")), + ], + options={ + "ordering": ["librenms_os"], + }, + bases=( + netbox_librenms_plugin.models.FullCleanOnSaveMixin, + netbox.models.deletion.DeleteMixin, + models.Model, + ), + ), ] diff --git a/netbox_librenms_plugin/migrations/0011_rename_related_names.py b/netbox_librenms_plugin/migrations/0011_rename_related_names.py deleted file mode 100644 index e431c2bdff..0000000000 --- a/netbox_librenms_plugin/migrations/0011_rename_related_names.py +++ /dev/null @@ -1,63 +0,0 @@ -# Generated by Django 5.2.10 on 2026-03-28 21:54 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("dcim", "0225_gfk_indexes"), - ("extras", "0134_owner"), - ("netbox_librenms_plugin", "0010_inventory_models"), - ] - - operations = [ - migrations.AlterUniqueTogether( - name="interfacetypemapping", - unique_together=set(), - ), - migrations.AlterUniqueTogether( - name="modulebaymapping", - unique_together=set(), - ), - migrations.AlterField( - model_name="devicetypemapping", - name="netbox_device_type", - field=models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="librenms_device_type_mappings", - to="dcim.devicetype", - ), - ), - migrations.AlterField( - model_name="inventoryignorerule", - name="enabled", - field=models.BooleanField(db_index=True, default=True), - ), - migrations.AlterField( - model_name="moduletypemapping", - name="netbox_module_type", - field=models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="librenms_module_type_mappings", - to="dcim.moduletype", - ), - ), - migrations.AlterField( - model_name="normalizationrule", - name="scope", - field=models.CharField(db_index=True, max_length=50), - ), - migrations.AddConstraint( - model_name="interfacetypemapping", - constraint=models.UniqueConstraint( - fields=("librenms_type", "librenms_speed"), name="unique_interface_type_mapping" - ), - ), - migrations.AddConstraint( - model_name="modulebaymapping", - constraint=models.UniqueConstraint( - fields=("librenms_name", "librenms_class"), name="unique_module_bay_mapping" - ), - ), - ] diff --git a/netbox_librenms_plugin/migrations/0012_normalization_rule_manufacturer_set_null.py b/netbox_librenms_plugin/migrations/0012_normalization_rule_manufacturer_set_null.py deleted file mode 100644 index 34f36a2c55..0000000000 --- a/netbox_librenms_plugin/migrations/0012_normalization_rule_manufacturer_set_null.py +++ /dev/null @@ -1,25 +0,0 @@ -# Generated by Django 5.2.10 on 2026-03-28 22:23 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("dcim", "0225_gfk_indexes"), - ("netbox_librenms_plugin", "0011_rename_related_names"), - ] - - operations = [ - migrations.AlterField( - model_name="normalizationrule", - name="manufacturer", - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="normalization_rules", - to="dcim.manufacturer", - ), - ), - ] diff --git a/netbox_librenms_plugin/migrations/0013_platform_mapping.py b/netbox_librenms_plugin/migrations/0013_platform_mapping.py deleted file mode 100644 index 2d45ccaab4..0000000000 --- a/netbox_librenms_plugin/migrations/0013_platform_mapping.py +++ /dev/null @@ -1,50 +0,0 @@ -# Generated by Django 5.2.10 on 2026-03-31 06:40 - -import django.db.models.deletion -import netbox.models.deletion -import netbox_librenms_plugin.models -import taggit.managers -import utilities.json -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("dcim", "0225_gfk_indexes"), - ("extras", "0134_owner"), - ("netbox_librenms_plugin", "0012_normalization_rule_manufacturer_set_null"), - ] - - operations = [ - migrations.CreateModel( - name="PlatformMapping", - fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), - ("created", models.DateTimeField(auto_now_add=True, null=True)), - ("last_updated", models.DateTimeField(auto_now=True, null=True)), - ( - "custom_field_data", - models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder), - ), - ("librenms_os", models.CharField(max_length=255, unique=True)), - ("description", models.TextField(blank=True)), - ( - "netbox_platform", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="librenms_platform_mappings", - to="dcim.platform", - ), - ), - ("tags", taggit.managers.TaggableManager(through="extras.TaggedItem", to="extras.Tag")), - ], - options={ - "ordering": ["librenms_os"], - }, - bases=( - netbox_librenms_plugin.models.FullCleanOnSaveMixin, - netbox.models.deletion.DeleteMixin, - models.Model, - ), - ), - ]