Feat/oob sync#295
Conversation
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughAdds Out‑of‑Band (OOB) detection and normalisation, extends import validation to surface OOB/promote/merge reconciliation actions (including merge candidates), implements bulk-import collision detection, introduces HTMX endpoints to add as OOB, promote to host, and merge devices, and provides Stage‑2b “move to winner” migrate actions. Sync views (interfaces, cables, modules, IPs, VLANs) are scoped to the selected LibreNMS server and merge OOB controller data (tagged via _source). Utilities support dict-form librenms_id, OOB helpers, merging/marker semantics. Templates, JS and tables render OOB badges and HTMX flows. Extensive tests and docs updated; URLs/export surfaces extended. Sequence Diagram(s)sequenceDiagram
participant User
participant ValidationView
participant ActionsView
participant NetBoxDB
participant LibreNMSAPI
User->>ValidationView: open device validation
ValidationView->>LibreNMSAPI: fetch device/os/hardware
ValidationView->>NetBoxDB: find by librenms_id / serial / hostname
ValidationView-->>User: show Add-as-OOB / Promote / Merge options
User->>ActionsView: POST Add-as-OOB (device_id, oob_id, type, iface)
ActionsView->>NetBoxDB: set librenms_id[{server}].oob and optionally attach OOB IP/interface
ActionsView-->>User: HX-Trigger validationRefresh
User->>ActionsView: POST Merge (winner, donor)
ActionsView->>NetBoxDB: merge librenms links, mark donor._migrated_to
ActionsView-->>User: HX-Refresh
Possibly related PRs
|
There was a problem hiding this comment.
Actionable comments posted: 13
🧹 Nitpick comments (4)
docs/usage_tips/mapping_rules.md (2)
3-3: 💤 Low valueConsider adding a comma for clarity.
The sentence "with them you can cover vendor naming variations..." would read more clearly as "with them, you can cover vendor naming variations...".
✍️ Suggested improvement
-Mapping rules are the configuration layer that connects LibreNMS identifiers to NetBox objects. Without them the plugin relies on exact-string matching; with them you can cover vendor naming variations, OS string aliases, model-number differences, and bay naming schemes across your entire fleet. +Mapping rules are the configuration layer that connects LibreNMS identifiers to NetBox objects. Without them the plugin relies on exact-string matching; with them, you can cover vendor naming variations, OS string aliases, model-number differences, and bay naming schemes across your entire fleet.
195-195: 💤 Low valueConsider adding article for clarity.
The phrase "to have bay to live in" would be clearer as "to have a bay to live in".
✍️ Suggested improvement
-Some chassis report child components (CPMs, MDAs, mezzanines) without the intermediate carrier/holder module that must first exist in NetBox for those children to have bay to live in. A Carrier Auto-Install Rule tells the plugin: "for this manufacturer / device type, when you see an orphan component of this class and name pattern, suggest installing this ModuleType into the matching empty bay." +Some chassis report child components (CPMs, MDAs, mezzanines) without the intermediate carrier/holder module that must first exist in NetBox for those children to have a bay to live in. A Carrier Auto-Install Rule tells the plugin: "for this manufacturer / device type, when you see an orphan component of this class and name pattern, suggest installing this ModuleType into the matching empty bay."netbox_librenms_plugin/tests/test_coverage_device_operations.py (1)
1692-1720: ⚡ Quick winPatch VC detection in the shared OOB test fixture to keep these tests deterministic.
validate_device_for_import()will still execute VC detection in these tests (api is set, vc detection defaults on), which makes OOB assertions depend on unrelated logic. Add a defaultget_virtual_chassis_datapatch in_base_patches()so these tests stay scoped and stable.Proposed diff
def _base_patches(self, mock_device_cls, mock_vm_cls=None): @@ return [ 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.cache"), patch("ipam.models.IPAddress"), patch("virtualization.models.VirtualMachine", new=mock_vm_cls), + patch( + "netbox_librenms_plugin.import_utils.device_operations.get_virtual_chassis_data", + return_value={"is_stack": False, "member_count": 0, "members": [], "detection_error": None}, + ), patch( "netbox_librenms_plugin.import_utils.device_operations.match_librenms_hardware_to_device_type", return_value={"matched": False}, ),netbox_librenms_plugin/tests/test_migrate_views.py (1)
275-277: ⚡ Quick winTighten save-call assertions for partial-field writes.
On Line 276 and Line 277,
assert_called_once()is too broad; this path should explicitly enforceupdate_fields=["oob_ip"]on both saves, matching the view’s concurrency/validation-safe contract.Proposed test tightening
- winner.save.assert_called_once() - donor.save.assert_called_once() + winner.save.assert_called_once_with(update_fields=["oob_ip"]) + donor.save.assert_called_once_with(update_fields=["oob_ip"])
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 76d85bcd-7195-40c2-a3ac-ed4764a00939
⛔ Files ignored due to path filters (13)
docs/img/Netbox-librenms-plugin-device-sync-fields.pngis excluded by!**/*.pngdocs/img/Netbox-librenms-plugin-import-page.pngis excluded by!**/*.pngdocs/img/Netbox-librenms-plugin-module-sync-tab.pngis excluded by!**/*.pngdocs/img/carrier_auto_install_rules/list.pngis excluded by!**/*.pngdocs/img/device_type_mappings/list.pngis excluded by!**/*.pngdocs/img/inventory_ignore_rules/list.pngis excluded by!**/*.pngdocs/img/module_bay_mappings/list.pngis excluded by!**/*.pngdocs/img/module_type_mappings/add.pngis excluded by!**/*.pngdocs/img/module_type_mappings/list.pngis excluded by!**/*.pngdocs/img/normalization_rules/add.pngis excluded by!**/*.pngdocs/img/normalization_rules/list.pngis excluded by!**/*.pngdocs/img/platform_mappings/add.pngis excluded by!**/*.pngdocs/img/platform_mappings/list.pngis excluded by!**/*.png
📒 Files selected for processing (49)
docs/README.mddocs/feature_list.mddocs/librenms_import/validation.mddocs/usage_tips/README.mddocs/usage_tips/mapping_rules.mddocs/usage_tips/module_sync.mdnetbox_librenms_plugin/constants.pynetbox_librenms_plugin/forms.pynetbox_librenms_plugin/import_utils/__init__.pynetbox_librenms_plugin/import_utils/bulk_import.pynetbox_librenms_plugin/import_utils/collisions.pynetbox_librenms_plugin/import_utils/device_operations.pynetbox_librenms_plugin/import_utils/ip_helpers.pynetbox_librenms_plugin/import_utils/vm_operations.pynetbox_librenms_plugin/librenms_api.pynetbox_librenms_plugin/migrations/0011_librenmssettings_auto_create_ipam_default.pynetbox_librenms_plugin/models.pynetbox_librenms_plugin/static/netbox_librenms_plugin/js/librenms_import.jsnetbox_librenms_plugin/tables/cables.pynetbox_librenms_plugin/tables/device_status.pynetbox_librenms_plugin/tables/interfaces.pynetbox_librenms_plugin/tables/modules.pynetbox_librenms_plugin/templates/netbox_librenms_plugin/_interface_sync_content.htmlnetbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/_dt_mapping_form.htmlnetbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/_platform_mapping_form.htmlnetbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/bulk_import_collision.htmlnetbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_import_row.htmlnetbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_validation_details.htmlnetbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_import.htmlnetbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.htmlnetbox_librenms_plugin/templates/netbox_librenms_plugin/settings.htmlnetbox_librenms_plugin/tests/test_collisions.pynetbox_librenms_plugin/tests/test_coverage_actions.pynetbox_librenms_plugin/tests/test_coverage_device_operations.pynetbox_librenms_plugin/tests/test_coverage_list.pynetbox_librenms_plugin/tests/test_import_utils.pynetbox_librenms_plugin/tests/test_ip_helpers.pynetbox_librenms_plugin/tests/test_librenms_id.pynetbox_librenms_plugin/tests/test_migrate_views.pynetbox_librenms_plugin/urls.pynetbox_librenms_plugin/utils.pynetbox_librenms_plugin/views/__init__.pynetbox_librenms_plugin/views/base/cables_view.pynetbox_librenms_plugin/views/base/interfaces_view.pynetbox_librenms_plugin/views/base/librenms_sync_view.pynetbox_librenms_plugin/views/base/modules_view.pynetbox_librenms_plugin/views/imports/actions.pynetbox_librenms_plugin/views/imports/list.pynetbox_librenms_plugin/views/sync/migrate.py
|
hi @bonzo81 - this probably could be split in 2 - as it has some UI improvements that could be separate - I will do that as I see CR has a lot to say on this one - will switch to draft for now. I'd expect to rebase that on top of your changes around module/interface sync, when that would be ready/in develop. |
|
Thanks @marcinpsk sounds fair enough. CR certainly was busy! Just created PR #296 as a module sync follow up |
- utils.py: use _type_normalized in OOB_TYPE_PATTERN.search to avoid TypeError when oob_type is None - device_status.py: coerce paired_oob_id/paired_host_id to int before comparison to handle mixed str/int values - device_operations.py: add created_ips:[] to all failure return paths in import_single_device for consistent response schema - cables_view.py: resolve OOB mapping from VC sync device instead of raw obj so VC pages load controller LLDP rows correctly - interfaces_view.py: guard chassis_member None for OOB port names that don't resolve to a VC member position - modules_view.py: use sync_device instead of obj when reading OOB mapping so VC pages find the mapping on the correct member - actions.py: only re-check change permission for concurrent PlatformMapping creation when the row actually needs updating (not a no-op) - list.py: replace inline auto_create_ipam fallback with resolve_auto_create_ipam() to honour request POST/GET toggle - librenms_import.js: align Bootstrap global detection order in validationRefresh handler to match rest of plugin - templates: log error.message in fetch catch blocks for diagnosability - test_collisions.py: use token-based set assertion to avoid substring false positives on role strings
- utils.py: use _type_normalized in OOB_TYPE_PATTERN.search to avoid TypeError when oob_type is None - device_status.py: coerce paired_oob_id/paired_host_id to int before comparison to handle mixed str/int values - device_operations.py: add created_ips:[] to all failure return paths in import_single_device for consistent response schema - cables_view.py: resolve OOB mapping from VC sync device instead of raw obj so VC pages load controller LLDP rows correctly - interfaces_view.py: guard chassis_member None for OOB port names that don't resolve to a VC member position - modules_view.py: use sync_device instead of obj when reading OOB mapping so VC pages find the mapping on the correct member - actions.py: only re-check change permission for concurrent PlatformMapping creation when the row actually needs updating (not a no-op) - list.py: replace inline auto_create_ipam fallback with resolve_auto_create_ipam() to honour request POST/GET toggle - librenms_import.js: align Bootstrap global detection order in validationRefresh handler to match rest of plugin - templates: log error.message in fetch catch blocks for diagnosability - test_collisions.py: use token-based set assertion to avoid substring false positives on role strings
- utils.py: use _type_normalized in OOB_TYPE_PATTERN.search to avoid TypeError when oob_type is None - device_status.py: coerce paired_oob_id/paired_host_id to int before comparison to handle mixed str/int values - device_operations.py: add created_ips:[] to all failure return paths in import_single_device for consistent response schema - cables_view.py: resolve OOB mapping from VC sync device instead of raw obj so VC pages load controller LLDP rows correctly - interfaces_view.py: guard chassis_member None for OOB port names that don't resolve to a VC member position - modules_view.py: use sync_device instead of obj when reading OOB mapping so VC pages find the mapping on the correct member - actions.py: only re-check change permission for concurrent PlatformMapping creation when the row actually needs updating (not a no-op) - list.py: replace inline auto_create_ipam fallback with resolve_auto_create_ipam() to honour request POST/GET toggle - librenms_import.js: align Bootstrap global detection order in validationRefresh handler to match rest of plugin - templates: log error.message in fetch catch blocks for diagnosability - test_collisions.py: use token-based set assertion to avoid substring false positives on role strings
- utils.py: use _type_normalized in OOB_TYPE_PATTERN.search to avoid TypeError when oob_type is None - device_status.py: coerce paired_oob_id/paired_host_id to int before comparison to handle mixed str/int values - device_operations.py: add created_ips:[] to all failure return paths in import_single_device for consistent response schema - cables_view.py: resolve OOB mapping from VC sync device instead of raw obj so VC pages load controller LLDP rows correctly - interfaces_view.py: guard chassis_member None for OOB port names that don't resolve to a VC member position - modules_view.py: use sync_device instead of obj when reading OOB mapping so VC pages find the mapping on the correct member - actions.py: only re-check change permission for concurrent PlatformMapping creation when the row actually needs updating (not a no-op) - list.py: replace inline auto_create_ipam fallback with resolve_auto_create_ipam() to honour request POST/GET toggle - librenms_import.js: align Bootstrap global detection order in validationRefresh handler to match rest of plugin - templates: log error.message in fetch catch blocks for diagnosability - test_collisions.py: use token-based set assertion to avoid substring false positives on role strings
0521e1f to
7826081
Compare
- utils.py: use _type_normalized in OOB_TYPE_PATTERN.search to avoid TypeError when oob_type is None - device_status.py: coerce paired_oob_id/paired_host_id to int before comparison to handle mixed str/int values - device_operations.py: add created_ips:[] to all failure return paths in import_single_device for consistent response schema - cables_view.py: resolve OOB mapping from VC sync device instead of raw obj so VC pages load controller LLDP rows correctly - interfaces_view.py: guard chassis_member None for OOB port names that don't resolve to a VC member position - modules_view.py: use sync_device instead of obj when reading OOB mapping so VC pages find the mapping on the correct member - actions.py: only re-check change permission for concurrent PlatformMapping creation when the row actually needs updating (not a no-op) - list.py: replace inline auto_create_ipam fallback with resolve_auto_create_ipam() to honour request POST/GET toggle - librenms_import.js: align Bootstrap global detection order in validationRefresh handler to match rest of plugin - templates: log error.message in fetch catch blocks for diagnosability - test_collisions.py: use token-based set assertion to avoid substring false positives on role strings
- utils.py: use _type_normalized in OOB_TYPE_PATTERN.search to avoid TypeError when oob_type is None - device_status.py: coerce paired_oob_id/paired_host_id to int before comparison to handle mixed str/int values - device_operations.py: add created_ips:[] to all failure return paths in import_single_device for consistent response schema - cables_view.py: resolve OOB mapping from VC sync device instead of raw obj so VC pages load controller LLDP rows correctly - interfaces_view.py: guard chassis_member None for OOB port names that don't resolve to a VC member position - modules_view.py: use sync_device instead of obj when reading OOB mapping so VC pages find the mapping on the correct member - actions.py: only re-check change permission for concurrent PlatformMapping creation when the row actually needs updating (not a no-op) - list.py: replace inline auto_create_ipam fallback with resolve_auto_create_ipam() to honour request POST/GET toggle - librenms_import.js: align Bootstrap global detection order in validationRefresh handler to match rest of plugin - templates: log error.message in fetch catch blocks for diagnosability - test_collisions.py: use token-based set assertion to avoid substring false positives on role strings
- utils.py: use _type_normalized in OOB_TYPE_PATTERN.search to avoid TypeError when oob_type is None - device_status.py: coerce paired_oob_id/paired_host_id to int before comparison to handle mixed str/int values - device_operations.py: add created_ips:[] to all failure return paths in import_single_device for consistent response schema - cables_view.py: resolve OOB mapping from VC sync device instead of raw obj so VC pages load controller LLDP rows correctly - interfaces_view.py: guard chassis_member None for OOB port names that don't resolve to a VC member position - modules_view.py: use sync_device instead of obj when reading OOB mapping so VC pages find the mapping on the correct member - actions.py: only re-check change permission for concurrent PlatformMapping creation when the row actually needs updating (not a no-op) - list.py: replace inline auto_create_ipam fallback with resolve_auto_create_ipam() to honour request POST/GET toggle - librenms_import.js: align Bootstrap global detection order in validationRefresh handler to match rest of plugin - templates: log error.message in fetch catch blocks for diagnosability - test_collisions.py: use token-based set assertion to avoid substring false positives on role strings
- utils.py: use _type_normalized in OOB_TYPE_PATTERN.search to avoid TypeError when oob_type is None - device_status.py: coerce paired_oob_id/paired_host_id to int before comparison to handle mixed str/int values - device_operations.py: add created_ips:[] to all failure return paths in import_single_device for consistent response schema - cables_view.py: resolve OOB mapping from VC sync device instead of raw obj so VC pages load controller LLDP rows correctly - interfaces_view.py: guard chassis_member None for OOB port names that don't resolve to a VC member position - modules_view.py: use sync_device instead of obj when reading OOB mapping so VC pages find the mapping on the correct member - actions.py: only re-check change permission for concurrent PlatformMapping creation when the row actually needs updating (not a no-op) - list.py: replace inline auto_create_ipam fallback with resolve_auto_create_ipam() to honour request POST/GET toggle - librenms_import.js: align Bootstrap global detection order in validationRefresh handler to match rest of plugin - templates: log error.message in fetch catch blocks for diagnosability - test_collisions.py: use token-based set assertion to avoid substring false positives on role strings
- utils.py: use _type_normalized in OOB_TYPE_PATTERN.search to avoid TypeError when oob_type is None - device_status.py: coerce paired_oob_id/paired_host_id to int before comparison to handle mixed str/int values - device_operations.py: add created_ips:[] to all failure return paths in import_single_device for consistent response schema - cables_view.py: resolve OOB mapping from VC sync device instead of raw obj so VC pages load controller LLDP rows correctly - interfaces_view.py: guard chassis_member None for OOB port names that don't resolve to a VC member position - modules_view.py: use sync_device instead of obj when reading OOB mapping so VC pages find the mapping on the correct member - actions.py: only re-check change permission for concurrent PlatformMapping creation when the row actually needs updating (not a no-op) - list.py: replace inline auto_create_ipam fallback with resolve_auto_create_ipam() to honour request POST/GET toggle - librenms_import.js: align Bootstrap global detection order in validationRefresh handler to match rest of plugin - templates: log error.message in fetch catch blocks for diagnosability - test_collisions.py: use token-based set assertion to avoid substring false positives on role strings
9161826 to
0cc8fea
Compare
…y payloads, import validation
- migrate: reject a _migrated_to marker that points back at the donor (self-merge).
- set_librenms_oob: route a bare-int host id through coerce_librenms_id() so a stored
0/negative fails closed instead of wrapping into a bogus {"id": 0}.
- modules _build_row: run the OOB short-circuit BEFORE the integrated-child check so a
controller-fed row that matches an ancestor keeps its OOB status.
- modules post(): treat a success flag with a non-list inventory payload (host and OOB)
as a fetch failure instead of crashing the iterate/mutate.
- bulk_import: clear the stale 'role must be selected' blocker when a fresh lookup
resolves a row to an existing role-less device.
- device_validation_details: carry the active server_key on the Full Sync link.
- device_fields perm tests: assert against the real PlatformMapping, not a MagicMock.
…sceivers Bulk install (_install_single) re-resolves each item's bay from scratch. For an item with no traceable installed parent it scoped to top-level device bays only, so a synthetic transceiver rendered as a top-level row whose 'Transceiver N/M' bay is module-scoped (under an installed line card) was skipped as 'no matching bay' — even though the table matched it (it matches against the combined all_bays) and a single install (which trusts the table's resolved bay) succeeded. Extract _candidate_bays_for_item: when no parent module is traced, match against the same combined device + module-scoped bay set the table uses (device bays winning on a name collision), so bulk install is consistent with the table and single install. Parent-scoped resolution is unchanged.
…ind on adoption error - modules_view: reject inventory lists with non-dict entries (main + OOB) so a malformed LibreNMS payload ([None], ["bad"]) is treated as a fetch failure instead of 500-ing the iterate/mutate loop. - sync/modules: isolate template adoption from the primary interface bind so an adoption exception no longer clobbers an already-committed bind into a generic 'failed'; surface the adoption error as a warning instead. - test_modules_view: assert the exact cache key deleted on the inventory-failure path. - Tests for all three paths.
…ad, log suppressed bind/adopt errors - modules_view: keep the OOB-inventory-failure toast generic (ids stay in the server log, matching the ports/transceiver toasts). - modules_view: treat a get_ports() dict whose 'ports' is missing/None or has non-dict entries as a fetch failure, so a degraded port-id-enrichment snapshot is no longer cached as complete (warns instead). - sync/modules: add a module logger and logger.exception() in the isolated bind/adoption except blocks so suppressed tracebacks are captured server-side (the toast promises 'see server logs for details'). - Tests for all three.
…'t fake bind success
- ip_addresses_view: _resolve_management_ip() only strips a str ip (a {"ip": 123}
payload no longer 500s the fresh refresh); validate get_device_ips() returns a
list of dicts before enriching, else treat as a fetch failure (no empty snapshot
cached under a success banner).
- modules_view: _merge_transceiver_data() rejects a non-list / non-dict-entry
transceiver payload as an error instead of crashing on txr.get(...).
- sync/modules: distinguish 'no primary to bind' from 'bind never attempted'
(bind_item present but server_key blank) so the latter reports a failure instead
of falling into the adoption-only success path.
- Tests for all four.
…ing iface, reset stale VM cluster - sync/modules: _modules_redirect_response() now propagates the active server_key (read from the request when not passed) so server-scoped module actions return to the same server tab; all inline ?tab=modules redirects route through it. - migrate: _reconcile_donor_device_ip_fks() locks the owning Interface and re-reads device_id from the locked row, so a concurrent interface move can't leave the winner owning an address that has moved off it (TOCTOU). - import_utils/bulk_import: a dropped cached VM match now resets the stale cluster selection (preserving available_clusters), mirroring the device_role reset. - test_coverage_device_fields: assert the platform is assigned + saved in the mapping-permission-denied branch. - Tests for each; updated the stale deleted-VM readiness test to the corrected contract.
…nt save order; guard server_key in redirects - migrate: _reconcile_donor_device_ip_fks() now clears the donor FK before the winner claims the address. device.primary_ip4/6/oob_ip are UNIQUE per address, so the old winner-first save violated the constraint whenever a transfer actually ran — surfaced by converting the test off mocks to real NetBox models. - test_migrate_views: replace the mock-based reconcile tests with real Device/Interface/ IPAddress objects (transfer + foreign-interface skip), exercising the lock + FK move end-to-end. - test_server_key_in_redirects: new guard asserting every '?tab=' URL under views/sync, views/base, views/object_sync propagates server_key — a recurring review finding, now locked in one place.
Rewrite TestRefreshExistingDevice against real NetBox models (Device, VirtualMachine, Cluster, Interface, IPAddress, real librenms_id custom fields) instead of MagicMock stand-ins. The refresh re-queries the DB to detect changes since caching (matched object deleted, librenms link removed, fresh match by name/serial/IP), so the conversion exercises the real ORM lookups, linkage re-derivation, role/cluster reset and readiness recompute end to end. This surfaced that the prior neutralized-link test only asserted can_import=True because its validation dict omitted site/device_type: recalculate_validation_status raised KeyError, the production except swallowed it, and the function returned before the fresh-lookup re-evaluation ran. With a correctly shaped dict the re-evaluation runs and re-blocks the row on the new-import role requirement; the test now asserts that real contract. Three cases stay as focused mocks with justification: the NOT-NULL Device.role defensive branch (unconstructable with a real Device) and two forced-exception logging paths (a real query can't be made to fail on demand).
Rewrite TestGetMigratedToMarker, TestBuildMigratedContext and TestResolveWinnerForDonor against real NetBox Devices with real librenms_id custom fields instead of MagicMock donors and patched Device querysets. get_migrated_to_marker reads the marker through the real device.cf accessor; build_migrated_context / _resolve_winner_for_donor resolve the winner via real Device.objects lookups (including the self-pointing and winner-deleted cases via real pk reuse/deletion). Removes the per-test MagicMock/queryset plumbing in favor of a shared _make_migrate_device helper, exercising the actual CF read and ORM resolution end to end.
Add dependency-free builder helpers to tests/conftest.py (make_device, make_vm, make_cluster, make_serial_device, cable_together, ip_on, delete_keeping_pk) and refactor the DB-backed conversions to use them, removing the per-file Site/Manufacturer/DeviceType/DeviceRole quartets that each test module was hand-rolling. No new test dependency (factory_boy was considered but not adopted): the builders are plain functions using get_or_create for shared infra, called from @pytest.mark.django_db tests. The mock-based local _make_device in TestProcessDeviceFilters is intentionally left untouched (it builds a MagicMock for orchestration tests, not a real row).
Rewrite TestSuggestOOBInterface, TestResolveOOBInterface and TestAttachOOBIp against real Device / Interface / IPAddress instead of patched model querysets. This exercises the actual behaviour the helpers exist for: the OOB-name interface scan, the select_for_update (device, name) reuse lookup, real Interface creation + full_clean validation (a 500-char name now fails real validation, not a mocked side_effect), and the address__net_host IP lookup with its ownership / ambiguity / re-home / slash-32-create branches. request.user.has_perm stays mocked — auth is an external boundary; the mock request grants perms by default and the denial tests deny a single perm to pin the specific check. The one select_for_update call-assertion test stays a focused mock (a concurrency primitive a single-threaded real test can't observe). Adds make_interface / make_ip to the shared conftest builders.
Rewrite TestSyncIPAddressesViewInterfaceResolution against real Device / Interface / IPAddress. _build_interface_maps now indexes real interfaces carrying real librenms_id port-id custom fields (the duplicate-port-id ambiguity is exercised by two real interfaces sharing a stored id), and the stale-interface_url regression runs the real process_ip_sync flow: a real IPAddress is created and bound to the re-resolved interface, rather than asserting kwargs on a patched IPAddress manager. Note: set_librenms_device_id only mutates custom_field_data (callers persist), so the tests save the interface before _build_interface_maps re-queries it.
…OneToOne constraint The merge view's oob_ip transfer set winner.oob_ip = donor.oob_ip then saved the winner before the donor. With both rows momentarily pointing at the same IP, the unique constraint on Device.oob_ip is violated. Save the donor first (releasing the IP) before the winner claims it. Convert the two merge-view POST classes to real DB: the transfer is now proven by what persists and reloads (catching the constraint bug a MagicMock save hid), and the donor-derivation role is proven by which device gets the persisted _migrated_to marker. Only the LibreNMS validation pipeline, auth gates, and row HTML render stay mocked.
The nine DB-driven AddAsOOBView.post tests now run against real Devices: the concurrency guards (get_librenms_oob / get_librenms_device_id / find_by_librenms_id) and the set_librenms_oob linkage execute for real and the result is reloaded from the DB, so a guard reading the wrong custom-field shape can no longer pass on a synthesized MagicMock. Only the LibreNMS validation pipeline, auth gates, and the row HTML render stay mocked. The early-return input guards and the forced save-error rollback-wiring test remain unit tests (no DB state to exercise / forced-error boundary).
The two detector-dependent VCNormalizationReportView paths stubbed
detect_vc_normalization_noop's return, so the view's integration with the real
detector + report builder was never exercised. Drive them against a real VC member
with a real Module: a NetBox InterfaceTemplate whose name carries no {module}/
{vc_position} token instantiates verbatim, so a Cisco-shaped name (matches the regex
→ real detector returns None → 400) and a Nokia-shaped name (no match → real
diagnostic → real build_vc_normalization_report rendered through the view) are both
produced for real. The early-return module_id guards stay unit tests.
Leaves TestVCNormalizationE2E as-is: it feeds controlled instantiated names into the
real regex/rewrite/detector, so its mocks already sit at the NetBox-ORM boundary and
our logic runs real — a full-DB rewrite would mostly exercise NetBox's instantiate().
… migrate tests Address CodeRabbit findings on PR #79: - ip_addresses_view._prepare_context: the fresh-refresh guard validated only the container shape, so a dict row missing the address/prefix or port_id fields _create_base_ip_entry() reads would KeyError mid-enrichment and 500. Validate the per-row schema and fail closed. Regression test added (fails pre-fix, passes post). - test_server_key_in_redirects: emit offender paths relative to the views package instead of the bare basename, so a failure is unambiguous when two scoped files share a name. - test_migrate_views: skip-path test now also asserts the donor keeps its IP (donor.primary_ip4_id == ip.pk), and the IP-move happy path is driven against real DB so the reassignment landing on the winner's same-named interface proves the donor re-lock and winner lookup used the correct device/name.
…s_data A malformed LibreNMS/cache ports payload (rows that are strings or null) caused port.get() to raise AttributeError and 500 the cables refresh, on both the main-device and OOB ports loops. Guard each row with isinstance(port, dict) (and the main loop's ports container with a list check, mirroring the OOB branch). Regression tests feed malformed rows and assert the valid row still resolves.
In migrated mode the VLAN sync form is replaced by a plain <div> so a migrated
donor cannot POST. The CSRF token and POST-only hidden inputs (server_key,
action) were still rendered inside that div — inert dead markup. Gate them on
{% if not migrated_to_marker %} so they only render with the real form. Adds a
render test asserting the migrated div emits no form/CSRF/hidden inputs while
normal mode still does.
…te path The docstring claimed the OOB-ports-fetch-failure path drops the partial cache entry and renders via fresh_data. That path was reworked to cache the host-only snapshot tagged oob_incomplete=True and render from cache (banner surfaces the missing OOB rows); no caller passes fresh_data anymore. Describe fresh_data as the render-from-snapshot escape hatch it is and stop describing removed behavior. Documentation-only; no behavior change.
The .playwright-mcp/ directory (page snapshots + console logs written by the Playwright MCP server during local UI verification) was committed by accident. Remove it from tracking and add it to .gitignore so these local artifacts no longer land in the repo.
The merge-candidate detection paired the hostname-matched device with a serial peer via .first(). Serial is not unique in NetBox (and device names are only unique per site), so .first() could pick an arbitrary same-serial row and surface the wrong merge target. Fetch up to two peers and only pair on exactly one; with more than one, skip the suggestion and warn. Applied symmetrically to the serial-matched/hostname-peer branch. DB-backed tests cover the multi-peer (warn, no suggestion) and unique-peer cases.
The three migrate/transfer reject-path tests stopped at the response assertion, so a regression that partially mutated the interface/IP/FK and then returned the same error toast would still pass. Add no-mutation guards: the collision reject keeps the interface on the donor and saves nothing; the winner-already-has-field reject leaves donor.primary_ip4 intact and saves neither device; the winner-lacks-interface reject leaves the IP on the donor interface and never persists it.
The _sync_redirect server_key tests covered allowlisted/spoofed/fallback handling but never the url_has_allowed_host_and_scheme=False path that _sync_url already pins. Add a test that an allowlisted server_key is still dropped when the redirect URL fails validation, so a regression reflecting a known key into a rejected URL is caught.
… return The interface refresh POST returned early on a missing librenms_id before the cache invalidation ran, so a failed refresh on a previously-synced device left the old ports snapshot in cache — the redirected sync tab (and downstream sync actions that load via CacheMixin.get_cache_key) would then serve/consume stale interface data. Move the cache.delete() calls ahead of the librenms_id lookup; they only depend on the already-resolved lookup_device/server_key. Regression test asserts the snapshot is cleared on the missing-id path (fails against the old ordering).
…-vals server_key
The interface table still renders interactive relationship/VC-member dropdowns in
migrated mode, and their verify-interface POST reads the token via
document.querySelector('[name=csrfmiddlewaretoken]'). Dropping the POST form in
migrated mode removed the only guaranteed token on the tab, so those JS requests hit
a null token (TypeError/403). Emit a standalone hidden csrfmiddlewaretoken in the
migrated <div> (a bare input never auto-submits, so it doesn't reintroduce the
live-form problem the form-drop avoided).
Also escapejs the server_key embedded in the migrated-mode hx-vals JSON so a key with
quotes/backslashes can't break JSON parsing and block the transfer/move action.
Adds a DB-backed render test asserting the token survives in migrated mode while the
form and form-only server_key input do not (and the form path still emits all three).
- migrate reject-path tests now assert the _fail() HTMX contract (HX-Reswap:none and no HX-Refresh) so a regression that returned HX-Refresh=true — refreshing as if the move/transfer succeeded — is caught. - _attach_messages_oob render-error test captures the storage and asserts used is False, pinning that a render failure doesn't leave queued messages consumed. - refresh-existing-device log test checks all positional args for the pk instead of only the format string, so a switch to parameterized logging stays green when correct.
…payloads, and non-Redis caches - cables: an OOB-only sync device has no host LibreNMS id, so the host LLDP fetch fails. Don't return None on that failure — fall through so the OOB merge runs and OOB-only devices still render their cable rows (returns None only when nothing at all is found). - interfaces: validate the OOB get_ports() payload (dict with a list of dict rows) before enriching/merging; a malformed-but-truthy response now follows the host-only oob_incomplete warning path instead of 500-ing. - interfaces + vlans: guard cache.ttl() with getattr — ttl() is Redis-specific and not part of the Django cache API, so other backends raised AttributeError at render (mirrors ip_addresses_view / modules_view). - vlans: evict the server-scoped VLAN snapshot on both refresh-failure exits so a failed refresh after a prior success can't leave a stale table for the next GET to render.
The outer-modal dismiss guard correctly stops nested data-bs-dismiss buttons from closing the outer HTMX modal, but when Bootstrap's modal JS is unavailable those nested buttons did nothing, leaving the user stuck in the child dialog. Manually hide just the nested modal in that fallback (Bootstrap still handles it natively when present).
…unique-peer merge - merge-peer discovery slices the queryset (exclude(...)[:2]); update the two skip-case mocks to stub the slice (__getitem__) instead of the unused .first() so the intended branch runs. - the single-serial-peer test only asserted absence of the multi-peer warning, which passes even with merge detection disabled. Give the peer a LibreNMS link (so the conservative guard fires) and assert the positive outcome: serial_action == merge_netbox_devices + candidates.
Summary
Builds on #303 (feat/ipam). Lets a single NetBox device represent a physical server while pulling data from two LibreNMS devices — the in-band host (the OS, polled via SNMP/agent on the production NIC) and the out-of-band management controller (iDRAC / iLO / IPMI / BMC, polled via its dedicated management IP). Sync makes it obvious which LibreNMS source each row came from, and pairing / merging an existing host+OOB pair (or promoting an already-imported OOB-only device to a full host) becomes an import action instead of manual cleanup.
Motivation / Problem
Feature.
Servers typically appear in LibreNMS as two separate devices: the in-band host and the out-of-band management controller. They share one physical chassis but expose different inventories, interfaces and IPs. NetBox models them as one device with an OOB IP and management interface — there is no separate iDRAC device. Today the plugin doesn't bridge that mismatch:
Goal: let one NetBox device represent the physical box and pull data from both LibreNMS devices:
Would close #289.
Scope of Change
Highlights (delta over feat/ipam):
_sourcebadge showing whether the row came from the host or the OOB device.idrac0, with confirm/override). This satisfies NetBox's requirement thatoob_ipbe interface-assigned; no unassigned/global IP addresses are created.librenms_idlinkage — the OOB sub-object stores only{id, type}; the controller's IP/version are read live from LibreNMS via the id rather than denormalised into the custom field.How Was This Tested?
librenms_idcoercion + OOB helpers, and import-action contracts (test_collisions.py,test_migrate_views.py,test_coverage_*,test_import_validation_helpers.py,test_librenms_id.py).Manual Test Steps
_sourcebadge.Risk Assessment
Backwards Compatibility
librenms_idcustom field.Other Notes
views/imports/actions.py, device migration inviews/sync/migrate.py, and the OOB linkage helpers inutils.py.