Skip to content

feat: multi-server librenms_id — track device origin per LibreNMS server - #20

Closed
marcinpsk wants to merge 19 commits into
pr/librenms-pre-id-multi-serverfrom
pr/librenms-id-multi-server
Closed

feat: multi-server librenms_id — track device origin per LibreNMS server #20
marcinpsk wants to merge 19 commits into
pr/librenms-pre-id-multi-serverfrom
pr/librenms-id-multi-server

Conversation

@marcinpsk

@marcinpsk marcinpsk commented Mar 5, 2026

Copy link
Copy Markdown
Owner

Summary

Replaces the raw integer librenms_id custom field with a JSON dict {"server_key": device_id} so that devices imported from (or linked to) multiple LibreNMS servers are unambiguously tracked back to their origin. The change is backwards-compatible: legacy bare-integer values continue to work and can be migrated in-place through the UI.

Depends on PR #23 (pr/librenms-pre-id-multi-server) — merge that first.
PR #21 (pr/librems-id-multi-fixes) stacks on top of this one.

Motivation / Problem

  • Feature

The existing librenms_id custom field stores a single integer — the device ID on the LibreNMS server that last touched this NetBox object. This breaks with multiple LibreNMS servers:

  • Two servers can independently assign the same integer ID to different physical devices
  • There is no way to tell which server an existing link belongs to
  • Re-importing from a second server silently overwrites the first server's link

Scope of Change

  • Sync/Import logic
  • LibreNMS API interaction
  • Config / settings
  • Web UI / templates
  • Tests

How Was This Tested?

  • Unit tests: yes — 571 tests pass (pytest in devcontainer), covering helpers, views, sync flows, and view wiring
  • Manual testing: yes — tested with single and multi-server configs, legacy and new format devices, VC setups
File Coverage
test_librenms_id.py get/set/find_by/migrate_legacy helpers — legacy int passthrough, bool rejection, type-error handling, dict merging, ORM query shape
test_mixins.py CacheMixin.get_cache_key with and without server_key
test_sync_devices.py device_fields.py sync actions: name, serial, device type, platform, server mapping, legacy ID conversion
test_sync_interfaces.py update_interface_attributes, handle_mac_address, set_librenms_device_id path, None port_id guard
test_sync_view_mismatch.py Mismatch detection logic and _build_all_server_mappings ordering / orphaned-server handling
test_view_wiring.py Smoke tests — MRO, mixin wiring, permission contracts, template syntax for all views including new ones

Risk Assessment

  • Does this change affect existing users?
    Yes — but existing devices with bare-integer librenms_id continue to work without any action. Migration is explicit and manual via a UI button.
  • Could this cause unintended imports / updates?
    No — newly imported devices use the dict format automatically; existing devices are not auto-migrated.

Backwards Compatibility

  • No breaking changes
  1. Existing devices with a bare-integer librenms_id continue to work — get_librenms_device_id(obj, "default") returns the integer for any server_key
  2. find_by_librenms_id() matches both legacy bare-int and new dict formats
  3. Migration is explicit and manual via the Migrate ID format button in the device validation details panel
  4. After migration the field shape changes from 42 to {"default": 42} (or {"your_server_key": 42})
  5. Newly imported devices always use the dict format from the start
  6. No Django migration needed — the librenms_id custom field type remains JSONField; only the stored value shape changes

Other Notes

New storage format

# Legacy (still readable, migrated via explicit UI action)
librenms_id = 42

# New
librenms_id = {"production": 42}
librenms_id = {"production": 42, "lab": 7}   # device visible on both servers

All reads/writes go through helpers in utils.py:

Helper Purpose
get_librenms_device_id(obj, server_key) Read ID for a specific server; handles legacy int transparently
set_librenms_device_id(obj, device_id, server_key) Write ID; rejects legacy bare-int records (use migration workflow)
find_by_librenms_id(model, id, server_key) ORM lookup matching both legacy int and new dict format
migrate_legacy_librenms_id(obj, server_key) Explicit one-shot migration; returns True if a migration happened

Key changes (delta from PR #23)

  • utils.py — Four new public ID functions plus get_librenms_sync_device with server-aware VC resolution
  • librenms_api.pyserver_key instance attribute, fallback logic for missing server config
  • views/mixins.py — Optional server_key in CacheMixin.get_cache_key()
  • views/sync/device_fields.pyRemoveServerMappingView, ConvertLegacyLibreNMSIdView with collision checks and select_for_update() locking
  • views/base/librenms_sync_view.py_build_all_server_mappings() with sorted server entries
  • views/base/cables_view.py — Server-aware cache keys, VC cable verify
  • views/imports/actions.py — Server-aware linking/unlinking, bool rejection in migration pre-checks
  • templates/librenms_sync_base.html — Server card with per-entry Remove button
  • templates/htmx/device_validation_details.html — Per-server badges, Migrate ID format button
  • urls.pyremove-server-mapping and convert-legacy-librenms-id endpoints

@coderabbitai

coderabbitai Bot commented Mar 5, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

🗂️ Base branches to auto review (3)
  • develop
  • master
  • main

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI (base), Organization UI (inherited)

Review profile: ASSERTIVE

Plan: Pro

Run ID: 26c42bee-11fe-4d03-94af-209d84404650

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds per-server (server_key) LibreNMS ID handling and migration; threads server_key through API, utils, views, cache keys, import/sync flows, templates, and JS; switches cable/interface identifiers to local_port_id; adds convert/remove mapping views and broad test coverage for multi-server behavior.

Changes

Cohort / File(s) Summary
Core utils & API
netbox_librenms_plugin/utils.py, netbox_librenms_plugin/librenms_api.py
Add per-server get/set/find/migrate librenms_id helpers; migrate/normalize legacy IDs; librenms_api delegates id retrieval/storage to utils and uses server_key for lookups/persistence.
Cache helpers & mixins
netbox_librenms_plugin/import_utils/cache.py, netbox_librenms_plugin/views/mixins.py
Centralize location-choices cache key helper and add optional server_key parameters to cache key methods for server-scoped cache keys.
Sync core & mappings
netbox_librenms_plugin/views/base/librenms_sync_view.py
Add _build_all_server_mappings and get_module_context; include legacy/migration flags in context and resolve sync devices via librenms_api.server_key.
Import/validation & actions
netbox_librenms_plugin/views/imports/actions.py, netbox_librenms_plugin/import_utils/device_operations.py
Thread server_key through validation/import flows, use server-aware find_by_librenms_id, add librenms_id_needs_migration flag, chassis-based device-type fallback, and transactional/select_for_update migration/conversion logic.
New views & routes
netbox_librenms_plugin/views/sync/device_fields.py, netbox_librenms_plugin/views/__init__.py, netbox_librenms_plugin/urls.py
Add ConvertLegacyLibreNMSIdView and RemoveServerMappingView, re-export them and register routes (remove-server-mapping, convert-legacy-id).
Sync subviews (interfaces/IPs/VLANs/cables/devices/VMs)
netbox_librenms_plugin/views/sync/*.py, netbox_librenms_plugin/views/object_sync/*.py
Many sync views gain LibreNMSAPIMixin; read server_key from POST, preserve it in redirects/URLs, normalize sync_device via get_librenms_sync_device(..., server_key), and scope cache operations to server_key.
Tables & identifiers
netbox_librenms_plugin/tables/interfaces.py, netbox_librenms_plugin/tables/cables.py, netbox_librenms_plugin/tables/device_status.py
Pass server_key into table constructors; switch stable identifiers to local_port_id; render librenms_id via utils; add aria-label/title attributes for accessibility.
Views base: cables / IPs / VLANs
netbox_librenms_plugin/views/base/cables_view.py, .../ip_addresses_view.py, .../vlan_table_view.py
Add _librenms_id_q for server-scoped + legacy matching; thread server_key through enrichment and cache lookups; adopt local_port_id matching and CSRF handling for generated forms.
Frontend & templates
netbox_librenms_plugin/static/.../librenms_sync.js, .../librenms_import.js, templates/..., forms.py
Include server_key in AJAX payloads and hidden form inputs; stronger fetch error handling; use bootstrap.Tooltip.getOrCreateInstance; render per-server mappings and migration UI; add rel/aria attributes.
Import utilities & bulk import
netbox_librenms_plugin/import_utils/bulk_import.py, .../vm_operations.py, .../virtual_chassis.py
Pass api.server_key through validate/import paths; VC domain derived from sorted member serials; VC member naming pattern is configurable with safe fallback.
Tests & fixtures
netbox_librenms_plugin/tests/*, netbox_librenms_plugin/tests/conftest.py
Large test additions/updates exercising multi-server librenms_id helpers, mixins, sync views, import flows, verifications, and wiring; PLUGINS_CONFIG fixtures added; many mocks made Q-aware.
Minor templates & small fixes
netbox_librenms_plugin/templates/_*_content.html, netbox_librenms_plugin/import_utils/cache.py, static JS
Add hidden server_key inputs in partials; centralize location choices key helper; rename payload fields (local_portlocal_port_id); small UI/accessibility refinements.

Sequence Diagram(s)

sequenceDiagram
    autonumber
    participant Client as Client (browser)
    participant View as Sync/View
    participant API as LibreNMSAPI
    participant Utils as Utils
    participant Cache as Cache
    participant DB as NetBoxDB

    Client->>View: GET/POST (may include server_key)
    View->>API: ensure LibreNMSAPI instance (server_key)
    View->>Cache: get_cache_key(obj, data_type, server_key)
    alt cache miss
        View->>API: fetch remote data
        API-->>View: remote data
        View->>Utils: find_by_librenms_id(..., server_key)
        Utils->>DB: select_for_update / set_librenms_device_id(...)
        DB-->>Utils: ack
        View->>Cache: set(cache_key(server_key), data)
    else cache hit
        Cache-->>View: cached data
    end
    View->>Client: render UI (per-server mappings, migrate/remove actions)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰 I hopped through keys both old and new,

For every server a cozy view,
IDs tucked in per-server nests,
Cached and scoped for tidy tests,
A little thump — migrations through.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 60.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main feature: adding multi-server support to the librenms_id field by tracking device origin per LibreNMS server.
Description check ✅ Passed The PR description comprehensively covers motivation, scope, testing, risk assessment, backwards compatibility, and technical notes with sufficient detail for reviewers.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch pr/librenms-id-multi-server

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@marcinpsk

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Mar 6, 2026

Copy link
Copy Markdown
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 11

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html (1)

604-645: 🛠️ Refactor suggestion | 🟠 Major

Use the standard sync wrapper/content templates for the new Modules tab.

This adds a new sync resource, but it is wired through inc/_module_sync.html instead of the usual _module_sync.html / _module_sync_content.html pair the other tabs follow. Keeping Modules outside that structure will make its refresh flow drift from the rest of the sync UI.

As per coding guidelines: Each sync resource has two templates following a naming convention: _<resource>_sync.html (the tab wrapper, loaded once when the tab is selected) and _<resource>_sync_content.html (the HTMX-swappable inner fragment, refreshed on data changes without a full page reload). When adding a new sync resource, create both the wrapper and content templates following this pattern.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html`
around lines 604 - 645, The Modules tab is using the nonstandard
inc/_module_sync.html include (id="modules", modules-tab), breaking the shared
sync wrapper/content pattern; create a proper wrapper template named
_module_sync.html that matches the other tabs (contains the tab-pane wrapper
with data-tab-id="modules" and includes the swappable fragment
_module_sync_content.html), replace the current include of inc/_module_sync.html
with {% include 'netbox_librenms_plugin/_module_sync.html' %}, and ensure you
add the inner fragment _module_sync_content.html to be used for HTMX refreshes
so Modules follows the same refresh flow as interfaces/cables/ipaddresses.
netbox_librenms_plugin/views/sync/interfaces.py (1)

101-110: ⚠️ Potential issue | 🔴 Critical

Use the server-aware cache key for ports.

get_cached_ports_data() still reads the unsuffixed ports cache entry. In a multi-server setup this can either miss the freshly fetched cache for the active server or, worse, pull another server's port list into this sync run. Pass self.librenms_api.server_key into get_cache_key() here.

🔧 Minimal fix
     def get_cached_ports_data(self, request, obj):
         """Return cached LibreNMS port data for the given object."""
-        cached_data = cache.get(self.get_cache_key(obj, "ports"))
+        cached_data = cache.get(self.get_cache_key(obj, "ports", self.librenms_api.server_key))
         if not cached_data:
             messages.warning(
                 request,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@netbox_librenms_plugin/views/sync/interfaces.py` around lines 101 - 110,
get_cached_ports_data currently retrieves the unsuffixed "ports" cache entry
which can return stale or cross-server data; change the cache lookup to use the
server-aware cache key by passing self.librenms_api.server_key into
get_cache_key (i.e., call self.get_cache_key(obj, "ports",
self.librenms_api.server_key)) so the method reads the per-server cached ports
list and returns cached_data.get("ports", []) as before.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@netbox_librenms_plugin/forms.py`:
- Around line 584-585: The cache key write was changed to include the server_key
(cache_key = f"librenms_locations_choices:{api.server_key}") while the reader in
import_utils.py still expects the legacy "librenms_locations_choices" key;
update one side so both match. Fix by either (A) having the writer also populate
the legacy key in addition to the namespaced key, or (B) change the reader in
import_utils.py to look up f"librenms_locations_choices:{api.server_key}" (using
the same LibreNMSAPI.server_key) when resolving location IDs to names; ensure
the code paths around LibreNMSAPI and the cache lookup use the identical key
format.

In
`@netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_validation_details.html`:
- Around line 379-385: The template expects a root-level existing_id_servers but
DeviceValidationDetailsView.get() (in
netbox_librenms_plugin/views/imports/actions.py) does not set it; update
DeviceValidationDetailsView.get() to add existing_id_servers to the template
context by extracting it from the validation data (e.g., existing_id_servers =
validation.get("existing_id_servers") or building the list from validation)
before rendering so the HTMX fragment and view response remain in sync with the
template's loop over existing_id_servers.

In `@netbox_librenms_plugin/tests/test_librenms_id.py`:
- Around line 71-83: The test should verify the actual predicate passed into
mock_model.objects.filter by inspecting mock_model.objects.filter.call_args and
asserting it includes both the legacy-integer branch and the server-key JSON
branch used by find_by_librenms_id; update the assertions in
test_queries_server_key_and_legacy_integer (and the similar block at 107-118) to
check that the filter call arguments contain the expected clauses (e.g. the
legacy id clause and the server-key clause or the combined Q object) rather than
only asserting filter() was invoked.

In `@netbox_librenms_plugin/tests/test_mixins.py`:
- Around line 111-150: Update the tests to cover the new per-server cache suffix
by calling get_cache_key and get_last_fetched_key with the server_key argument
and asserting the returned keys include that server identifier; specifically
modify/add assertions in tests that reference get_cache_key (e.g.,
test_get_cache_key_format, test_get_cache_key_includes_model_name,
test_get_cache_key_different_data_types) to call mixin.get_cache_key(obj,
"ports", server_key="srv1") and assert "srv1" (or the server_key used) appears
in the key, and update test_get_last_fetched_key_format to call
mixin.get_last_fetched_key(obj, "ports", server_key="srv1") and assert the
server_key is included alongside "last_fetched", model_name and pk.

In `@netbox_librenms_plugin/tests/test_sync_devices.py`:
- Around line 142-145: The test currently only checks that 42 appears in the
positional args for view._librenms_api.update_device_field; change this to
assert the exact call payload so the field and value are validated. Replace the
loose checks with a single assertion like
view._librenms_api.update_device_field.assert_called_once_with(42,
expected_field, expected_value) (or construct expected_payload and assert
call_args[0] == (42, expected_payload)), where expected_field/expected_value
match the intended site/field values being synced, then keep
mock_msg.success.assert_called_once() as-is.

In `@netbox_librenms_plugin/tests/test_sync_interfaces.py`:
- Around line 69-70: The test's current check uses iface.method_calls which
won't detect attribute assignment, so change the test to initialize the
interface with a unique sentinel (use
self._make_device_interface(speed=object()) or similar) before calling
view.update_interface_attributes(iface, librenms_data, "1000base-t", {"speed"},
"ifName") and then assert that iface.speed is still the same sentinel after the
call to prove the excluded "speed" field was not mutated.

In `@netbox_librenms_plugin/tests/test_sync_view_mismatch.py`:
- Around line 331-416: Replace the hand-rolled plugin configs and MagicMock
NetBox objects in tests that call
BaseLibreNMSSyncView._build_all_server_mappings with the shared fixtures from
tests/conftest.py (e.g. use mock_multi_server_config or mock_legacy_config for
PLUGINS_CONFIG and the provided mock_netbox_device / mock_netbox_vm / related
NetBox object fixtures instead of _make_obj and inline plugins_cfg), and update
the test setup to inject those fixtures (via patching
django_settings.PLUGINS_CONFIG with the fixture values and passing/using the
mock NetBox object fixtures) so the tests rely on the centralized fixtures for
configuration, API client, and objects rather than ad‑hoc mocks.

In `@netbox_librenms_plugin/views/base/librenms_sync_view.py`:
- Around line 154-163: The code currently fabricates a legacy "default" server
from plugins_cfg top-level keys even when multi-server mode is active; update
the conditional so the legacy fallback only runs when no servers list is
configured (i.e., plugins_cfg.get("servers") is falsy or empty). Concretely,
change the existing check that uses srv_cfg is None and sk == "default" to also
require that plugins_cfg.get("servers") is missing/empty before assigning
srv_cfg from plugins_cfg["librenms_url"]/["display_name"]; leave is_configured
and other logic unchanged.

In `@netbox_librenms_plugin/views/sync/device_fields.py`:
- Around line 434-443: The code treats a legacy librenms_url as a configured
server even when a "servers" section exists; update the check so
legacy_url_configured is considered true only when a top-level "librenms_url"
exists AND there is no "servers" section (or it's empty). In practice, change
the condition using plugins_cfg, configured_servers and legacy_url_configured so
the branch reads something like: if server_key in configured_servers or
(legacy_url_configured and not configured_servers and server_key == "default"),
referencing the variables plugins_cfg, configured_servers, legacy_url_configured
and server_key to locate where to modify.
- Around line 405-410: RemoveServerMappingView is currently hard-coded to
Device; update it to handle both Device and VirtualMachine so VM librenms_id
mappings can be removed. Modify the required_object_permissions on
RemoveServerMappingView to include the VirtualMachine change permission (in
addition to Device), and change any internal logic that references Device
directly to derive the model dynamically (e.g., from the request payload or URL
param) and call the shared librenms_id helper functions with that model/class
instead of a Device literal; ensure permission checks, lookups and deletion
paths work for both Device and VirtualMachine and import VirtualMachine where
needed.

In `@netbox_librenms_plugin/views/sync/interfaces.py`:
- Around line 235-238: The current code only calls set_librenms_device_id(...)
when "librenms_id" already exists in interface.cf, which skips initializing the
mapping for new interfaces; change the logic in the block that reads
librenms_interface.get("port_id") so that whenever port_id is not None you call
set_librenms_device_id(interface, port_id, self.librenms_api.server_key)
regardless of whether "librenms_id" is present in interface.cf (so the helper
can create or migrate the value for first-time writes).

---

Outside diff comments:
In
`@netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html`:
- Around line 604-645: The Modules tab is using the nonstandard
inc/_module_sync.html include (id="modules", modules-tab), breaking the shared
sync wrapper/content pattern; create a proper wrapper template named
_module_sync.html that matches the other tabs (contains the tab-pane wrapper
with data-tab-id="modules" and includes the swappable fragment
_module_sync_content.html), replace the current include of inc/_module_sync.html
with {% include 'netbox_librenms_plugin/_module_sync.html' %}, and ensure you
add the inner fragment _module_sync_content.html to be used for HTMX refreshes
so Modules follows the same refresh flow as interfaces/cables/ipaddresses.

In `@netbox_librenms_plugin/views/sync/interfaces.py`:
- Around line 101-110: get_cached_ports_data currently retrieves the unsuffixed
"ports" cache entry which can return stale or cross-server data; change the
cache lookup to use the server-aware cache key by passing
self.librenms_api.server_key into get_cache_key (i.e., call
self.get_cache_key(obj, "ports", self.librenms_api.server_key)) so the method
reads the per-server cached ports list and returns cached_data.get("ports", [])
as before.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI (base), Organization UI (inherited)

Review profile: ASSERTIVE

Plan: Pro

Run ID: 1e156d47-708b-463d-bc98-e2272a4fe903

📥 Commits

Reviewing files that changed from the base of the PR and between 45b6b7a and 906ee8c.

📒 Files selected for processing (21)
  • netbox_librenms_plugin/forms.py
  • netbox_librenms_plugin/librenms_api.py
  • netbox_librenms_plugin/static/netbox_librenms_plugin/js/librenms_import.js
  • netbox_librenms_plugin/tables/device_status.py
  • netbox_librenms_plugin/tables/interfaces.py
  • netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_validation_details.html
  • netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html
  • netbox_librenms_plugin/tests/test_librenms_id.py
  • netbox_librenms_plugin/tests/test_mixins.py
  • netbox_librenms_plugin/tests/test_sync_devices.py
  • netbox_librenms_plugin/tests/test_sync_interfaces.py
  • netbox_librenms_plugin/tests/test_sync_view_mismatch.py
  • netbox_librenms_plugin/urls.py
  • netbox_librenms_plugin/utils.py
  • netbox_librenms_plugin/views/__init__.py
  • netbox_librenms_plugin/views/base/librenms_sync_view.py
  • netbox_librenms_plugin/views/mixins.py
  • netbox_librenms_plugin/views/object_sync/devices.py
  • netbox_librenms_plugin/views/object_sync/vms.py
  • netbox_librenms_plugin/views/sync/device_fields.py
  • netbox_librenms_plugin/views/sync/interfaces.py
📜 Review details
🧰 Additional context used
📓 Path-based instructions (13)
netbox_librenms_plugin/templates/**/*.html

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

Follow frontend conventions for templates and static files defined in .github/instructions/frontend.instructions.md

netbox_librenms_plugin/templates/**/*.html: HTMX 2.x is the primary async layer for table row updates. Table row updates must return <tr hx-swap-oob="true">. Avoid outerHTML swaps; use OOB (Out-of-Band) or targeted innerHTML swaps to keep table layout intact.
Styling assumes Tabler defaults. Removing table-responsive wrappers was deliberate to prevent dropdown clipping—do not re-add them.

Files:

  • netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html
  • netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_validation_details.html
netbox_librenms_plugin/{templates,static}/**/*.{html,js}

📄 CodeRabbit inference engine (.github/instructions/frontend.instructions.md)

netbox_librenms_plugin/{templates,static}/**/*.{html,js}: All HTMX requests and fetch() calls must include a CSRF token. The standard pattern is document.querySelector('[name=csrfmiddlewaretoken]').value (from a hidden form input). The import JS also uses getCookie('csrftoken') as a fallback — prefer the hidden input approach for consistency.
Modals use Tabler (Bootstrap-like) but without bootstrap.Modal helpers. Buttons target the htmx-modal-content element and JavaScript in librenms_import.html toggles the wrapper. Do not reintroduce data-bs-toggle or duplicate modal IDs.

Files:

  • netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html
  • netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_validation_details.html
  • netbox_librenms_plugin/static/netbox_librenms_plugin/js/librenms_import.js
netbox_librenms_plugin/{templates,tables}/**/*.{html,py}

📄 CodeRabbit inference engine (.github/instructions/frontend.instructions.md)

Templates live in templates/netbox_librenms_plugin/; reuse/includes under inc/. Sync pages extend librenms_sync_base.html. Tables emit HTMX-enabled columns and buttons (tables/*.py), so prefer updating the table renderer in Python rather than templates when changing row actions.

Files:

  • netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html
  • netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_validation_details.html
  • netbox_librenms_plugin/tables/interfaces.py
  • netbox_librenms_plugin/tables/device_status.py
netbox_librenms_plugin/**/*.py

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

netbox_librenms_plugin/**/*.py: Hook into NetBox Django 5 plugin APIs via navigation.py, urls.py, and api/ modules under netbox_librenms_plugin/; respect NetBox plugin conventions
Use LibreNMSAPI.get_librenms_id() instead of directly accessing the librenms_id custom field when mapping Devices/VMs to LibreNMS
Reuse librenms_api.py client for all LibreNMS communication instead of making direct requests calls; it handles multi-server configs via LibreNMSSettings model and caching
Always use exact-only matching (not fuzzy) for site, platform, device type, and role via functions in utils.py (find_matching_site, match_librenms_hardware_to_device_type, find_matching_platform)
Centralize validation state mutation during import using import_validation_helpers.py for role/cluster/rack assignment, issue removal, and status recalculation
Use get_virtual_chassis_member() for port-to-member mapping and get_librenms_sync_device() for VC priority-based device selection in virtual chassis operations

Files:

  • netbox_librenms_plugin/forms.py
  • netbox_librenms_plugin/tests/test_librenms_id.py
  • netbox_librenms_plugin/urls.py
  • netbox_librenms_plugin/tables/interfaces.py
  • netbox_librenms_plugin/utils.py
  • netbox_librenms_plugin/tests/test_sync_interfaces.py
  • netbox_librenms_plugin/views/sync/interfaces.py
  • netbox_librenms_plugin/tests/test_sync_view_mismatch.py
  • netbox_librenms_plugin/views/__init__.py
  • netbox_librenms_plugin/tests/test_sync_devices.py
  • netbox_librenms_plugin/views/mixins.py
  • netbox_librenms_plugin/tests/test_mixins.py
  • netbox_librenms_plugin/tables/device_status.py
  • netbox_librenms_plugin/views/sync/device_fields.py
  • netbox_librenms_plugin/views/object_sync/devices.py
  • netbox_librenms_plugin/views/object_sync/vms.py
  • netbox_librenms_plugin/librenms_api.py
  • netbox_librenms_plugin/views/base/librenms_sync_view.py
netbox_librenms_plugin/templates/**/htmx/*.html

📄 CodeRabbit inference engine (.github/instructions/frontend.instructions.md)

HTMX fragments live in templates/netbox_librenms_plugin/htmx/ and include: device_import_row.html (individual import row updates), device_validation_details.html (expandable validation details), device_vc_details.html (virtual chassis member details), bulk_import_confirm.html (import confirmation modal content). Keep server responses and HTMX targets in sync when modifying these fragments.

Files:

  • netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_validation_details.html
**/tables/**/*.py

📄 CodeRabbit inference engine (.github/instructions/sync.instructions.md)

Table classes in tables/ must use ToggleColumn(attrs={'input': {'name': 'select'}}) for selection, accept contextual parameters in constructors (e.g., device, interface_name_field, vlan_groups), set self.tab and self.prefix for multi-table pagination, include data-* attributes in row attrs, and VLAN columns must use render_vlans() with hidden inputs and JSON data.

Files:

  • netbox_librenms_plugin/tables/interfaces.py
  • netbox_librenms_plugin/tables/device_status.py
netbox_librenms_plugin/views/**/*.py

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

netbox_librenms_plugin/views/**/*.py: Views must follow a three-layer structure: Base views in views/base/, Object sync views in views/object_sync/, and Sync action views in views/sync/ with shared mixins from views/mixins.py
New views must extend the closest base class and compose mixins from views/mixins.py (LibreNMSPermissionMixin, NetBoxObjectPermissionMixin, LibreNMSAPIMixin, CacheMixin, VlanAssignmentMixin)
All views must inherit LibreNMSPermissionMixin from views/mixins.py with permission_required = PERM_VIEW_PLUGIN
Declare required_object_permissions as a dict mapping HTTP methods to [(action, Model)] tuples for NetBox model operations; some views may set this dynamically per-request
Use _get_safe_redirect_url(request) to validate referrer URLs in permission checks to prevent open-redirect attacks

Files:

  • netbox_librenms_plugin/views/sync/interfaces.py
  • netbox_librenms_plugin/views/__init__.py
  • netbox_librenms_plugin/views/mixins.py
  • netbox_librenms_plugin/views/sync/device_fields.py
  • netbox_librenms_plugin/views/object_sync/devices.py
  • netbox_librenms_plugin/views/object_sync/vms.py
  • netbox_librenms_plugin/views/base/librenms_sync_view.py
netbox_librenms_plugin/views/sync/**/*.py

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

netbox_librenms_plugin/views/sync/**/*.py: Sync POST handlers must call require_all_permissions() (not just require_write_permission()) and return early if it returns a response; use require_all_permissions_json() for AJAX/JSON endpoints
Follow sync conventions defined in .github/instructions/sync.instructions.md for sync views, base views, tables, and sync JavaScript

Files:

  • netbox_librenms_plugin/views/sync/interfaces.py
  • netbox_librenms_plugin/views/sync/device_fields.py
**/views/sync/**/*.py

📄 CodeRabbit inference engine (.github/instructions/sync.instructions.md)

Sync action views must follow the pattern: check permissions with LibreNMSPermissionMixin and NetBoxObjectPermissionMixin, read selected items from request.POST.getlist('select'), load cached data using CacheMixin.get_cache_key(), apply changes inside transaction.atomic(), and redirect to the sync tab with ?tab=<resource>.

Files:

  • netbox_librenms_plugin/views/sync/interfaces.py
  • netbox_librenms_plugin/views/sync/device_fields.py
netbox_librenms_plugin/static/**/*import*.js

📄 CodeRabbit inference engine (.github/instructions/frontend.instructions.md)

netbox_librenms_plugin/static/**/*import*.js: The import page uses ModalManager class and filterModalManager instance—always use this reference in fetch callbacks, not undefined modalInstance variables.
The import filter form uses fetch with Accept: application/json, text/html—JSON for background jobs, HTML for synchronous mode.
Import page JavaScript (librenms_import.js) is wrapped in an IIFE with window.LibreNMSImportInitialized guard to prevent re-initialization during HTMX swaps.
The ModalManager class wraps Bootstrap 5 modal show/hide with fallback.
The pollJobStatus() function polls /api/core/background-tasks/{jobId}/ every 2s, updates progress messages, handles cancel button, and redirects on completion.
The captureSelectionState() and restoreSelectionState() functions preserve checkbox state across HTMX content swaps.
The initializeFilterForm() function intercepts form submit, detects JSON response (background job), and starts polling.
CSRF token extraction should use getCookie('csrftoken') (cookie-based) as the approach for the import page JavaScript.

Files:

  • netbox_librenms_plugin/static/netbox_librenms_plugin/js/librenms_import.js
netbox_librenms_plugin/static/**/*.js

📄 CodeRabbit inference engine (.github/instructions/frontend.instructions.md)

netbox_librenms_plugin/static/**/*.js: Always check response.ok before processing fetch responses to catch HTTP errors. In catch blocks, show error.message for debugging rather than generic messages.
The createCacheCountdown() function is a generic countdown timer for cache expiration display.

Files:

  • netbox_librenms_plugin/static/netbox_librenms_plugin/js/librenms_import.js
**/views/object_sync/**/*.py

📄 CodeRabbit inference engine (.github/instructions/sync.instructions.md)

Object sync view methods must create instances of concrete table views, copy the request object, and call get_context_data(). VMs must skip cables and VLANs by returning None from those get_*_context() methods.

Files:

  • netbox_librenms_plugin/views/object_sync/devices.py
  • netbox_librenms_plugin/views/object_sync/vms.py
**/views/base/**/*.py

📄 CodeRabbit inference engine (.github/instructions/sync.instructions.md)

**/views/base/**/*.py: Base view classes (BaseLibreNMSSyncView, BaseInterfaceTableView, BaseCableTableView, BaseIPAddressTableView, BaseVLANTableView) must implement the data pipeline pattern: fetch data from LibreNMS API, cache results with CacheMixin keys like librenms_{data_type}_{model_name}_{pk}, compare against NetBox objects, and render a django-tables2 table in a partial template.
Base table view classes must implement resource-specific comparison logic: interface matching by name, IP matching by address/mask, VLAN matching by VID+group, and cables by matching remote devices and checking cable status.
VlanAssignmentMixin must resolve VLAN group scope in order: Rack → Location → Site → SiteGroup → Region → Global, and must provide auto-selection of the most-specific VLAN group and lookup map building for interface and VLAN sync.

Files:

  • netbox_librenms_plugin/views/base/librenms_sync_view.py
🧠 Learnings (50)
📚 Learning: 2026-03-03T13:24:18.134Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/frontend.instructions.md:0-0
Timestamp: 2026-03-03T13:24:18.134Z
Learning: Applies to netbox_librenms_plugin/{templates,tables}/**/*.{html,py} : Templates live in `templates/netbox_librenms_plugin/`; reuse/includes under `inc/`. Sync pages extend `librenms_sync_base.html`. Tables emit HTMX-enabled columns and buttons (`tables/*.py`), so prefer updating the table renderer in Python rather than templates when changing row actions.

Applied to files:

  • netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html
  • netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_validation_details.html
  • netbox_librenms_plugin/tables/interfaces.py
  • netbox_librenms_plugin/views/sync/interfaces.py
  • netbox_librenms_plugin/views/__init__.py
  • netbox_librenms_plugin/tests/test_sync_devices.py
  • netbox_librenms_plugin/tables/device_status.py
  • netbox_librenms_plugin/views/sync/device_fields.py
  • netbox_librenms_plugin/views/object_sync/devices.py
  • netbox_librenms_plugin/views/object_sync/vms.py
  • netbox_librenms_plugin/views/base/librenms_sync_view.py
📚 Learning: 2026-03-03T13:24:18.134Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/frontend.instructions.md:0-0
Timestamp: 2026-03-03T13:24:18.134Z
Learning: Applies to netbox_librenms_plugin/templates/**/_*_sync{,_content}.html : Each sync resource has two templates following a naming convention: `_<resource>_sync.html` (the tab wrapper, loaded once when the tab is selected) and `_<resource>_sync_content.html` (the HTMX-swappable inner fragment, refreshed on data changes without a full page reload). Current resources: `_interface_sync`, `_cable_sync`, `_ipaddress_sync`, `_vlan_sync`. When adding a new sync resource, create both the wrapper and content templates following this pattern.

Applied to files:

  • netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html
📚 Learning: 2026-03-03T13:24:18.134Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/frontend.instructions.md:0-0
Timestamp: 2026-03-03T13:24:18.134Z
Learning: Applies to netbox_librenms_plugin/templates/**/htmx/*.html : HTMX fragments live in `templates/netbox_librenms_plugin/htmx/` and include: `device_import_row.html` (individual import row updates), `device_validation_details.html` (expandable validation details), `device_vc_details.html` (virtual chassis member details), `bulk_import_confirm.html` (import confirmation modal content). Keep server responses and HTMX targets in sync when modifying these fragments.

Applied to files:

  • netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html
  • netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_validation_details.html
  • netbox_librenms_plugin/urls.py
  • netbox_librenms_plugin/views/__init__.py
  • netbox_librenms_plugin/static/netbox_librenms_plugin/js/librenms_import.js
  • netbox_librenms_plugin/tables/device_status.py
📚 Learning: 2026-03-03T13:23:33.731Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-03-03T13:23:33.731Z
Learning: Applies to netbox_librenms_plugin/views/sync/**/*.py : Follow sync conventions defined in `.github/instructions/sync.instructions.md` for sync views, base views, tables, and sync JavaScript

Applied to files:

  • netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html
  • netbox_librenms_plugin/tests/test_sync_interfaces.py
  • netbox_librenms_plugin/views/sync/interfaces.py
  • netbox_librenms_plugin/tests/test_sync_view_mismatch.py
  • netbox_librenms_plugin/views/__init__.py
  • netbox_librenms_plugin/tests/test_sync_devices.py
  • netbox_librenms_plugin/views/sync/device_fields.py
  • netbox_librenms_plugin/views/object_sync/vms.py
  • netbox_librenms_plugin/views/base/librenms_sync_view.py
📚 Learning: 2026-03-03T13:23:33.731Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-03-03T13:23:33.731Z
Learning: Sync pipelines must follow the pattern: fetch LibreNMS data via `librenms_api.py`, cache with `CacheMixin`, build comparison tables via `tables/*.py`, and render HTMX fragments from `templates/netbox_librenms_plugin/htmx/`

Applied to files:

  • netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html
📚 Learning: 2026-03-03T13:23:33.731Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-03-03T13:23:33.731Z
Learning: Applies to netbox_librenms_plugin/**/*.py : Use `get_virtual_chassis_member()` for port-to-member mapping and `get_librenms_sync_device()` for VC priority-based device selection in virtual chassis operations

Applied to files:

  • netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html
  • netbox_librenms_plugin/tables/interfaces.py
  • netbox_librenms_plugin/utils.py
  • netbox_librenms_plugin/tests/test_sync_interfaces.py
  • netbox_librenms_plugin/views/sync/interfaces.py
  • netbox_librenms_plugin/tests/test_sync_view_mismatch.py
  • netbox_librenms_plugin/views/__init__.py
  • netbox_librenms_plugin/views/sync/device_fields.py
  • netbox_librenms_plugin/views/object_sync/devices.py
  • netbox_librenms_plugin/views/object_sync/vms.py
  • netbox_librenms_plugin/librenms_api.py
📚 Learning: 2026-03-03T13:24:18.134Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/frontend.instructions.md:0-0
Timestamp: 2026-03-03T13:24:18.134Z
Learning: Applies to netbox_librenms_plugin/{templates,static}/**/*.{html,js} : Modals use Tabler (Bootstrap-like) but without `bootstrap.Modal` helpers. Buttons target the `htmx-modal-content` element and JavaScript in `librenms_import.html` toggles the wrapper. Do not reintroduce `data-bs-toggle` or duplicate modal IDs.

Applied to files:

  • netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html
  • netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_validation_details.html
  • netbox_librenms_plugin/static/netbox_librenms_plugin/js/librenms_import.js
  • netbox_librenms_plugin/tables/device_status.py
📚 Learning: 2026-03-03T13:23:33.731Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-03-03T13:23:33.731Z
Learning: Applies to netbox_librenms_plugin/**/*.py : Centralize validation state mutation during import using `import_validation_helpers.py` for role/cluster/rack assignment, issue removal, and status recalculation

Applied to files:

  • netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html
  • netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_validation_details.html
  • netbox_librenms_plugin/tests/test_librenms_id.py
  • netbox_librenms_plugin/utils.py
  • netbox_librenms_plugin/views/sync/interfaces.py
  • netbox_librenms_plugin/views/__init__.py
  • netbox_librenms_plugin/tests/test_sync_devices.py
  • netbox_librenms_plugin/views/sync/device_fields.py
  • netbox_librenms_plugin/librenms_api.py
  • netbox_librenms_plugin/views/base/librenms_sync_view.py
📚 Learning: 2026-03-03T13:24:38.082Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/sync.instructions.md:0-0
Timestamp: 2026-03-03T13:24:38.082Z
Learning: Applies to **/librenms_sync.js : JavaScript URL and tab state management must implement `initializeTabs()`, `getDeviceIdFromUrl()`, and `setInterfaceNameFieldFromURL()` to maintain browser state and URL synchronization.

Applied to files:

  • netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html
📚 Learning: 2026-03-03T13:23:33.731Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-03-03T13:23:33.731Z
Learning: Applies to netbox_librenms_plugin/api/**/*.py : Update API serializers in `api/serializers.py` and `api/views.py` together to prevent contract drift between models and external API consumers

Applied to files:

  • netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html
  • netbox_librenms_plugin/tests/test_librenms_id.py
  • netbox_librenms_plugin/tables/interfaces.py
  • netbox_librenms_plugin/utils.py
  • netbox_librenms_plugin/tests/test_sync_interfaces.py
  • netbox_librenms_plugin/views/sync/interfaces.py
  • netbox_librenms_plugin/tests/test_sync_view_mismatch.py
  • netbox_librenms_plugin/views/__init__.py
  • netbox_librenms_plugin/tests/test_sync_devices.py
  • netbox_librenms_plugin/tests/test_mixins.py
  • netbox_librenms_plugin/views/sync/device_fields.py
  • netbox_librenms_plugin/views/object_sync/devices.py
  • netbox_librenms_plugin/views/object_sync/vms.py
  • netbox_librenms_plugin/librenms_api.py
  • netbox_librenms_plugin/views/base/librenms_sync_view.py
📚 Learning: 2026-03-03T13:24:38.082Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/sync.instructions.md:0-0
Timestamp: 2026-03-03T13:24:38.082Z
Learning: Applies to **/views/sync/**/*.py : Sync action views must follow the pattern: check permissions with `LibreNMSPermissionMixin` and `NetBoxObjectPermissionMixin`, read selected items from `request.POST.getlist('select')`, load cached data using `CacheMixin.get_cache_key()`, apply changes inside `transaction.atomic()`, and redirect to the sync tab with `?tab=<resource>`.

Applied to files:

  • netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html
  • netbox_librenms_plugin/views/sync/interfaces.py
  • netbox_librenms_plugin/tests/test_sync_devices.py
  • netbox_librenms_plugin/views/sync/device_fields.py
📚 Learning: 2026-03-03T13:23:33.731Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-03-03T13:23:33.731Z
Learning: Applies to netbox_librenms_plugin/views/**/*.py : Views must follow a three-layer structure: Base views in `views/base/`, Object sync views in `views/object_sync/`, and Sync action views in `views/sync/` with shared mixins from `views/mixins.py`

Applied to files:

  • netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html
  • netbox_librenms_plugin/urls.py
  • netbox_librenms_plugin/views/sync/interfaces.py
  • netbox_librenms_plugin/tests/test_sync_view_mismatch.py
  • netbox_librenms_plugin/views/__init__.py
  • netbox_librenms_plugin/tests/test_sync_devices.py
  • netbox_librenms_plugin/views/sync/device_fields.py
  • netbox_librenms_plugin/views/base/librenms_sync_view.py
📚 Learning: 2026-03-03T13:24:18.134Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/frontend.instructions.md:0-0
Timestamp: 2026-03-03T13:24:18.134Z
Learning: Applies to netbox_librenms_plugin/templates/**/inc/paginator.html : `inc/paginator.html` is a custom paginator that preserves tab state and `interface_name_field` in pagination URLs. Used across all sync tables.

Applied to files:

  • netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html
📚 Learning: 2026-03-03T13:23:33.731Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-03-03T13:23:33.731Z
Learning: Applies to netbox_librenms_plugin/**/*.py : Hook into NetBox Django 5 plugin APIs via `navigation.py`, `urls.py`, and `api/` modules under `netbox_librenms_plugin/`; respect NetBox plugin conventions

Applied to files:

  • netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html
  • netbox_librenms_plugin/urls.py
  • netbox_librenms_plugin/tables/interfaces.py
  • netbox_librenms_plugin/utils.py
  • netbox_librenms_plugin/views/sync/interfaces.py
  • netbox_librenms_plugin/views/__init__.py
  • netbox_librenms_plugin/tests/test_sync_devices.py
  • netbox_librenms_plugin/tests/test_mixins.py
  • netbox_librenms_plugin/librenms_api.py
  • netbox_librenms_plugin/views/base/librenms_sync_view.py
📚 Learning: 2026-03-03T13:23:33.731Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-03-03T13:23:33.731Z
Learning: Applies to netbox_librenms_plugin/**/*.py : Reuse `librenms_api.py` client for all LibreNMS communication instead of making direct `requests` calls; it handles multi-server configs via `LibreNMSSettings` model and caching

Applied to files:

  • netbox_librenms_plugin/forms.py
  • netbox_librenms_plugin/tests/test_librenms_id.py
  • netbox_librenms_plugin/tables/interfaces.py
  • netbox_librenms_plugin/utils.py
  • netbox_librenms_plugin/tests/test_mixins.py
  • netbox_librenms_plugin/librenms_api.py
  • netbox_librenms_plugin/views/base/librenms_sync_view.py
📚 Learning: 2026-03-03T13:24:38.082Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/sync.instructions.md:0-0
Timestamp: 2026-03-03T13:24:38.082Z
Learning: Applies to **/views/base/**/*.py|**/views/object_sync/**/*.py : Use `LibreNMSAPIMixin` to provide lazy-loaded `LibreNMSAPI` instance via `self.librenms_api` property and `get_server_info()` method for template context.

Applied to files:

  • netbox_librenms_plugin/forms.py
  • netbox_librenms_plugin/urls.py
  • netbox_librenms_plugin/views/sync/interfaces.py
  • netbox_librenms_plugin/tests/test_sync_devices.py
  • netbox_librenms_plugin/tests/test_mixins.py
  • netbox_librenms_plugin/views/object_sync/devices.py
  • netbox_librenms_plugin/views/object_sync/vms.py
  • netbox_librenms_plugin/librenms_api.py
  • netbox_librenms_plugin/views/base/librenms_sync_view.py
📚 Learning: 2026-03-03T13:24:51.364Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/testing.instructions.md:0-0
Timestamp: 2026-03-03T13:24:51.364Z
Learning: Applies to tests/**/*.py : Patch `cache` where imported: `netbox_librenms_plugin.views.imports.list.cache`, not `django.core.cache.cache`.

Applied to files:

  • netbox_librenms_plugin/forms.py
  • netbox_librenms_plugin/tests/test_sync_interfaces.py
  • netbox_librenms_plugin/tests/test_sync_view_mismatch.py
  • netbox_librenms_plugin/tests/test_mixins.py
📚 Learning: 2026-03-03T13:24:18.134Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/frontend.instructions.md:0-0
Timestamp: 2026-03-03T13:24:18.134Z
Learning: Applies to netbox_librenms_plugin/templates/**/htmx/*import*.html : Device import dropdowns rely on TomSelect decorators set up elsewhere. Keep `<select class="device-role-select">` markup stable to preserve JS hook-up.

Applied to files:

  • netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_validation_details.html
  • netbox_librenms_plugin/static/netbox_librenms_plugin/js/librenms_import.js
  • netbox_librenms_plugin/tables/device_status.py
📚 Learning: 2026-03-03T13:24:18.134Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/frontend.instructions.md:0-0
Timestamp: 2026-03-03T13:24:18.134Z
Learning: Applies to netbox_librenms_plugin/templates/**/*.html : HTMX 2.x is the primary async layer for table row updates. Table row updates must return `<tr hx-swap-oob="true">`. Avoid `outerHTML` swaps; use OOB (Out-of-Band) or targeted `innerHTML` swaps to keep table layout intact.

Applied to files:

  • netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_validation_details.html
  • netbox_librenms_plugin/static/netbox_librenms_plugin/js/librenms_import.js
  • netbox_librenms_plugin/tables/device_status.py
📚 Learning: 2026-03-03T13:24:00.657Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/background-jobs.instructions.md:0-0
Timestamp: 2026-03-03T13:24:00.657Z
Learning: Applies to **/views/imports/** : `DeviceValidationDetailsView` (GET) must render expandable validation details via `htmx/device_validation_details.html`

Applied to files:

  • netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_validation_details.html
📚 Learning: 2026-03-03T13:24:18.134Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/frontend.instructions.md:0-0
Timestamp: 2026-03-03T13:24:18.134Z
Learning: Applies to netbox_librenms_plugin/templates/**/*.html : Styling assumes Tabler defaults. Removing `table-responsive` wrappers was deliberate to prevent dropdown clipping—do not re-add them.

Applied to files:

  • netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_validation_details.html
📚 Learning: 2026-03-03T13:24:38.082Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/sync.instructions.md:0-0
Timestamp: 2026-03-03T13:24:38.082Z
Learning: Applies to **/librenms_sync.js : JavaScript verification functions must include `handleInterfaceChange()`, `handleCableChange()`, `handleVRFChange()` that POST to single-item verify endpoints to validate resource changes.

Applied to files:

  • netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_validation_details.html
  • netbox_librenms_plugin/tests/test_sync_interfaces.py
📚 Learning: 2026-03-03T13:24:18.134Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/frontend.instructions.md:0-0
Timestamp: 2026-03-03T13:24:18.134Z
Learning: Applies to netbox_librenms_plugin/static/**/*import*.js : The `captureSelectionState()` and `restoreSelectionState()` functions preserve checkbox state across HTMX content swaps.

Applied to files:

  • netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_validation_details.html
  • netbox_librenms_plugin/static/netbox_librenms_plugin/js/librenms_import.js
📚 Learning: 2026-03-03T13:24:18.134Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/frontend.instructions.md:0-0
Timestamp: 2026-03-03T13:24:18.134Z
Learning: Applies to netbox_librenms_plugin/templates/**/settings.html : `settings.html` uses a split-form pattern: two separate Django forms (`ServerConfigForm` + `ImportSettingsForm`) sharing one page, differentiated by a hidden `form_type` field (`"server_config"` or `"import_settings"`). The test-connection button is an HTMX POST to `TestLibreNMSConnectionView`, returning an inline alert fragment.

Applied to files:

  • netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_validation_details.html
📚 Learning: 2026-03-03T13:24:00.657Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/background-jobs.instructions.md:0-0
Timestamp: 2026-03-03T13:24:00.657Z
Learning: Applies to **/views/imports/** : Per-device dropdown update views (`DeviceRoleUpdateView`, `DeviceClusterUpdateView`, `DeviceRackUpdateView`) must apply selection to validation state and return re-rendered row via `render_device_row()`

Applied to files:

  • netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_validation_details.html
  • netbox_librenms_plugin/views/__init__.py
  • netbox_librenms_plugin/views/sync/device_fields.py
📚 Learning: 2026-03-03T13:24:00.657Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/background-jobs.instructions.md:0-0
Timestamp: 2026-03-03T13:24:00.657Z
Learning: Applies to **/views/imports/** : `BulkImportConfirmView` (POST) must render confirmation modal with selected device list, returning `htmx/bulk_import_confirm.html`

Applied to files:

  • netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_validation_details.html
📚 Learning: 2026-03-03T13:24:51.364Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/testing.instructions.md:0-0
Timestamp: 2026-03-03T13:24:51.364Z
Learning: Applies to tests/**/*.py : Reuse fixtures from `tests/conftest.py` instead of creating ad-hoc mocks: Configuration (`mock_multi_server_config`, `mock_legacy_config`), API client (`mock_librenms_api`), NetBox objects (`mock_netbox_device`, `mock_netbox_vm`, `mock_netbox_site`, `mock_netbox_platform`, `mock_netbox_device_type`, `mock_netbox_device_role`, `mock_netbox_cluster`, `mock_netbox_rack`), HTTP responses (`mock_response_factory`, `mock_success_response`, `mock_device_response`, `mock_error_response`, `mock_auth_error_response`), and Import workflow (`sample_librenms_device`, `sample_librenms_device_minimal`, `sample_validation_state`, `sample_validation_state_vm`).

Applied to files:

  • netbox_librenms_plugin/tests/test_librenms_id.py
  • netbox_librenms_plugin/tests/test_sync_interfaces.py
  • netbox_librenms_plugin/tests/test_sync_view_mismatch.py
  • netbox_librenms_plugin/tests/test_sync_devices.py
  • netbox_librenms_plugin/tests/test_mixins.py
📚 Learning: 2026-03-03T13:23:33.731Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-03-03T13:23:33.731Z
Learning: Applies to netbox_librenms_plugin/**/*.py : Use `LibreNMSAPI.get_librenms_id()` instead of directly accessing the `librenms_id` custom field when mapping Devices/VMs to LibreNMS

Applied to files:

  • netbox_librenms_plugin/tests/test_librenms_id.py
  • netbox_librenms_plugin/tables/interfaces.py
  • netbox_librenms_plugin/utils.py
  • netbox_librenms_plugin/views/sync/interfaces.py
  • netbox_librenms_plugin/tests/test_sync_view_mismatch.py
  • netbox_librenms_plugin/views/__init__.py
  • netbox_librenms_plugin/tables/device_status.py
  • netbox_librenms_plugin/views/sync/device_fields.py
  • netbox_librenms_plugin/views/object_sync/vms.py
  • netbox_librenms_plugin/librenms_api.py
📚 Learning: 2026-03-03T13:24:51.364Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/testing.instructions.md:0-0
Timestamp: 2026-03-03T13:24:51.364Z
Learning: Applies to tests/test_netbox_librenms_plugin.py : `test_netbox_librenms_plugin.py` is an empty placeholder — do not add tests there.

Applied to files:

  • netbox_librenms_plugin/tests/test_librenms_id.py
  • netbox_librenms_plugin/utils.py
  • netbox_librenms_plugin/tests/test_sync_interfaces.py
  • netbox_librenms_plugin/tests/test_sync_view_mismatch.py
  • netbox_librenms_plugin/tests/test_sync_devices.py
  • netbox_librenms_plugin/tests/test_mixins.py
  • netbox_librenms_plugin/librenms_api.py
📚 Learning: 2026-03-03T13:24:51.364Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/testing.instructions.md:0-0
Timestamp: 2026-03-03T13:24:51.364Z
Learning: Applies to tests/**/*.py : Patch deferred/inline imports at their source module (e.g., `netbox_librenms_plugin.import_utils.process_device_filters`), not the consuming module.

Applied to files:

  • netbox_librenms_plugin/tests/test_librenms_id.py
  • netbox_librenms_plugin/tests/test_sync_interfaces.py
  • netbox_librenms_plugin/tests/test_sync_view_mismatch.py
  • netbox_librenms_plugin/tests/test_sync_devices.py
  • netbox_librenms_plugin/tests/test_mixins.py
📚 Learning: 2026-03-03T13:24:51.364Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/testing.instructions.md:0-0
Timestamp: 2026-03-03T13:24:51.364Z
Learning: Applies to tests/**/*.py : Mock NetBox models (Device, Job, User) with `MagicMock()` instead of creating real instances.

Applied to files:

  • netbox_librenms_plugin/tests/test_librenms_id.py
  • netbox_librenms_plugin/tests/test_sync_interfaces.py
  • netbox_librenms_plugin/tests/test_sync_devices.py
📚 Learning: 2026-03-03T13:23:33.731Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-03-03T13:23:33.731Z
Learning: Keep REST endpoint server responses and HTMX targets in sync; implement REST endpoints in `views/imports/actions.py` and HTMX fragments in `templates/netbox_librenms_plugin/htmx/`

Applied to files:

  • netbox_librenms_plugin/urls.py
  • netbox_librenms_plugin/views/__init__.py
  • netbox_librenms_plugin/views/sync/device_fields.py
📚 Learning: 2026-03-03T13:23:33.731Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-03-03T13:23:33.731Z
Learning: Applies to netbox_librenms_plugin/views/**/*.py : New views must extend the closest base class and compose mixins from `views/mixins.py` (`LibreNMSPermissionMixin`, `NetBoxObjectPermissionMixin`, `LibreNMSAPIMixin`, `CacheMixin`, `VlanAssignmentMixin`)

Applied to files:

  • netbox_librenms_plugin/urls.py
  • netbox_librenms_plugin/views/sync/interfaces.py
  • netbox_librenms_plugin/tests/test_sync_devices.py
  • netbox_librenms_plugin/tests/test_mixins.py
  • netbox_librenms_plugin/views/base/librenms_sync_view.py
📚 Learning: 2026-03-03T13:24:38.082Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/sync.instructions.md:0-0
Timestamp: 2026-03-03T13:24:38.082Z
Learning: Applies to **/views/base/**/*.py : Base view classes (`BaseLibreNMSSyncView`, `BaseInterfaceTableView`, `BaseCableTableView`, `BaseIPAddressTableView`, `BaseVLANTableView`) must implement the data pipeline pattern: fetch data from LibreNMS API, cache results with `CacheMixin` keys like `librenms_{data_type}_{model_name}_{pk}`, compare against NetBox objects, and render a django-tables2 table in a partial template.

Applied to files:

  • netbox_librenms_plugin/urls.py
  • netbox_librenms_plugin/views/object_sync/vms.py
📚 Learning: 2026-03-03T13:24:38.082Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/sync.instructions.md:0-0
Timestamp: 2026-03-03T13:24:38.082Z
Learning: Applies to **/views/base/**/*.py : Base table view classes must implement resource-specific comparison logic: interface matching by name, IP matching by address/mask, VLAN matching by VID+group, and cables by matching remote devices and checking cable status.

Applied to files:

  • netbox_librenms_plugin/urls.py
  • netbox_librenms_plugin/views/object_sync/devices.py
📚 Learning: 2026-03-03T13:23:33.731Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-03-03T13:23:33.731Z
Learning: Applies to netbox_librenms_plugin/views/**/*.py : All views must inherit `LibreNMSPermissionMixin` from `views/mixins.py` with `permission_required = PERM_VIEW_PLUGIN`

Applied to files:

  • netbox_librenms_plugin/urls.py
  • netbox_librenms_plugin/views/sync/interfaces.py
  • netbox_librenms_plugin/tests/test_sync_devices.py
  • netbox_librenms_plugin/tests/test_mixins.py
📚 Learning: 2026-03-03T13:23:33.731Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-03-03T13:23:33.731Z
Learning: Applies to netbox_librenms_plugin/**/*.py : Always use exact-only matching (not fuzzy) for site, platform, device type, and role via functions in `utils.py` (`find_matching_site`, `match_librenms_hardware_to_device_type`, `find_matching_platform`)

Applied to files:

  • netbox_librenms_plugin/utils.py
  • netbox_librenms_plugin/librenms_api.py
📚 Learning: 2026-03-03T13:23:33.731Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-03-03T13:23:33.731Z
Learning: Applies to netbox_librenms_plugin/models.py : Coordinate schema changes through Django migrations in `migrations/` directory; update `models.py`, admin, and Pydantic representations accordingly

Applied to files:

  • netbox_librenms_plugin/utils.py
  • netbox_librenms_plugin/views/sync/device_fields.py
📚 Learning: 2026-03-03T13:23:33.731Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-03-03T13:23:33.731Z
Learning: Applies to netbox_librenms_plugin/jobs.py : Follow background job conventions defined in `.github/instructions/background-jobs.instructions.md` for `jobs.py`, import views, and import utilities

Applied to files:

  • netbox_librenms_plugin/utils.py
📚 Learning: 2026-03-03T13:24:38.082Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/sync.instructions.md:0-0
Timestamp: 2026-03-03T13:24:38.082Z
Learning: Applies to **/librenms_sync.js : JavaScript VLAN modal functions must implement `openVlanDetailModal()`, `verifyVlanInGroup()`, `verifyVlanSyncGroup()` for per-interface VLAN detail editing.

Applied to files:

  • netbox_librenms_plugin/tests/test_sync_interfaces.py
📚 Learning: 2026-03-03T13:24:38.082Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/sync.instructions.md:0-0
Timestamp: 2026-03-03T13:24:38.082Z
Learning: Applies to **/views/object_sync/**/*.py : Object sync view methods must create instances of concrete table views, copy the `request` object, and call `get_context_data()`. VMs must skip cables and VLANs by returning `None` from those `get_*_context()` methods.

Applied to files:

  • netbox_librenms_plugin/views/sync/interfaces.py
  • netbox_librenms_plugin/views/__init__.py
  • netbox_librenms_plugin/views/object_sync/vms.py
  • netbox_librenms_plugin/views/base/librenms_sync_view.py
📚 Learning: 2026-03-03T13:23:33.731Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-03-03T13:23:33.731Z
Learning: Applies to netbox_librenms_plugin/api/views.py : API endpoints must use `LibreNMSPluginPermission` class in `api/views.py` where GET checks `view_librenmssettings` and other methods check `change_librenmssettings`

Applied to files:

  • netbox_librenms_plugin/views/sync/interfaces.py
  • netbox_librenms_plugin/librenms_api.py
  • netbox_librenms_plugin/views/base/librenms_sync_view.py
📚 Learning: 2026-03-03T13:23:33.731Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-03-03T13:23:33.731Z
Learning: Applies to netbox_librenms_plugin/views/sync/**/*.py : Sync POST handlers must call `require_all_permissions()` (not just `require_write_permission()`) and return early if it returns a response; use `require_all_permissions_json()` for AJAX/JSON endpoints

Applied to files:

  • netbox_librenms_plugin/tests/test_sync_devices.py
  • netbox_librenms_plugin/views/sync/device_fields.py
📚 Learning: 2026-03-03T13:24:51.364Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/testing.instructions.md:0-0
Timestamp: 2026-03-03T13:24:51.364Z
Learning: Test business logic via the utility modules (import_utils.py, import_validation_helpers.py, etc.) they call, not via HTTP requests, for views in `views/sync/`, `views/object_sync/`, and `views/imports/actions.py`.

Applied to files:

  • netbox_librenms_plugin/tests/test_sync_devices.py
📚 Learning: 2026-03-03T13:24:18.134Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/frontend.instructions.md:0-0
Timestamp: 2026-03-03T13:24:18.134Z
Learning: Applies to netbox_librenms_plugin/static/**/*import*.js : The import page uses `ModalManager` class and `filterModalManager` instance—always use this reference in fetch callbacks, not undefined `modalInstance` variables.

Applied to files:

  • netbox_librenms_plugin/static/netbox_librenms_plugin/js/librenms_import.js
📚 Learning: 2026-03-03T13:24:18.134Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/frontend.instructions.md:0-0
Timestamp: 2026-03-03T13:24:18.134Z
Learning: Applies to netbox_librenms_plugin/static/**/*import*.js : Import page JavaScript (`librenms_import.js`) is wrapped in an IIFE with `window.LibreNMSImportInitialized` guard to prevent re-initialization during HTMX swaps.

Applied to files:

  • netbox_librenms_plugin/static/netbox_librenms_plugin/js/librenms_import.js
📚 Learning: 2026-03-03T13:24:18.134Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/frontend.instructions.md:0-0
Timestamp: 2026-03-03T13:24:18.134Z
Learning: Applies to netbox_librenms_plugin/static/**/*import*.js : The `ModalManager` class wraps Bootstrap 5 modal show/hide with fallback.

Applied to files:

  • netbox_librenms_plugin/static/netbox_librenms_plugin/js/librenms_import.js
📚 Learning: 2026-03-03T13:24:38.082Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/sync.instructions.md:0-0
Timestamp: 2026-03-03T13:24:38.082Z
Learning: Applies to **/librenms_sync.js : JavaScript TomSelect dropdown initialization must use a `TOMSELECT_INIT_DELAY_MS = 100` constant and implement delayed initialization after HTMX swaps. Required initializer functions: `initializeVCMemberSelect()`, `initializeVRFSelects()`, `initializeVlanGroupSelects()`, `initializeVlanSyncGroupSelects()`.

Applied to files:

  • netbox_librenms_plugin/static/netbox_librenms_plugin/js/librenms_import.js
📚 Learning: 2026-03-03T13:24:18.134Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/frontend.instructions.md:0-0
Timestamp: 2026-03-03T13:24:18.134Z
Learning: Applies to netbox_librenms_plugin/static/**/*import*.js : CSRF token extraction should use `getCookie('csrftoken')` (cookie-based) as the approach for the import page JavaScript.

Applied to files:

  • netbox_librenms_plugin/static/netbox_librenms_plugin/js/librenms_import.js
📚 Learning: 2026-03-03T13:24:38.082Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/sync.instructions.md:0-0
Timestamp: 2026-03-03T13:24:38.082Z
Learning: Applies to **/views/base/**/*.py|**/views/object_sync/**/*.py|**/views/sync/**/*.py : Cache keys for sync data must follow the format: `librenms_{data_type}_{model_name}_{pk}` for fetched data and `librenms_{data_type}_last_fetched_{model_name}_{pk}` for fetch timestamps. VLAN group overrides must use `get_vlan_overrides_key(obj)`.

Applied to files:

  • netbox_librenms_plugin/views/mixins.py
  • netbox_librenms_plugin/tests/test_mixins.py

Comment thread netbox_librenms_plugin/forms.py Outdated
Comment thread netbox_librenms_plugin/tests/test_librenms_id.py
Comment thread netbox_librenms_plugin/tests/test_mixins.py Outdated
Comment thread netbox_librenms_plugin/tests/test_sync_devices.py Outdated
Comment thread netbox_librenms_plugin/tests/test_sync_view_mismatch.py Outdated
Comment thread netbox_librenms_plugin/views/base/librenms_sync_view.py
Comment thread netbox_librenms_plugin/views/sync/device_fields.py
Comment thread netbox_librenms_plugin/views/sync/device_fields.py Outdated
Comment thread netbox_librenms_plugin/views/sync/interfaces.py Outdated
@marcinpsk
marcinpsk force-pushed the pr/librenms-id-multi-server branch 2 times, most recently from 7d85b98 to 71ac0b6 Compare March 6, 2026 08:38
@marcinpsk

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Mar 6, 2026

Copy link
Copy Markdown
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@marcinpsk
marcinpsk force-pushed the pr/librenms-id-multi-server branch from 71ac0b6 to c56ac4c Compare March 6, 2026 10:26
@marcinpsk

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Mar 6, 2026

Copy link
Copy Markdown
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
netbox_librenms_plugin/import_utils.py (2)

1327-1335: ⚠️ Potential issue | 🔴 Critical

New imports are still being stored in the legacy single-int format.

Both creation paths write custom_field_data={"librenms_id": int(...)} directly. That drops the originating server_key, so fresh imports from non-default LibreNMS instances are immediately indistinguishable from old single-server records. Persist the mapping through set_librenms_device_id(..., server_key) before saving both the Device and the VM.

Also applies to: 1766-1774

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@netbox_librenms_plugin/import_utils.py` around lines 1327 - 1335, The
new-import path is storing librenms_id as a single int in device_data
(custom_field_data={"librenms_id": int(device_id)}) which loses the server_key;
update the creation flow to persist the mapping via
set_librenms_device_id(server_key, int(device_id)) before saving the Device (and
likewise for the VM creation path), and remove/replace direct single-int
assignments so that both Device and VM use the set_librenms_device_id(...) call
to record the id+server_key mapping rather than writing the legacy single-int
custom field.

780-806: ⚠️ Potential issue | 🔴 Critical

Use the multi-server lookup helper here, not a raw integer CF filter.

Both queries still only match the legacy bare-integer shape (custom_field_data__librenms_id=int(...)). Once a Device or VM has been migrated to {"server_key": device_id}, this path stops recognizing it as already linked and the import flow can offer a duplicate import. Switch these lookups to find_by_librenms_id(..., server_key) so legacy ints and per-server mappings are both honored.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@netbox_librenms_plugin/import_utils.py` around lines 780 - 806, The existing
lookup uses raw integer custom-field filters
(VirtualMachine.objects.filter(custom_field_data__librenms_id=int(librenms_id))
and Device.objects.filter(...)) which misses migrated per-server mappings;
replace those filters with the multi-server helper
find_by_librenms_id(librenms_id, server_key=server_key) (or the project’s
equivalent) to return the existing VM/Device, so both legacy ints and
{"server_key": id} shapes are honored; remove the int(...) conversion/ValueError
handling around those filters and assign the helper result to existing_vm and
existing_device respectively, keeping the subsequent result[...] assignments and
name-matching logic unchanged.
netbox_librenms_plugin/tables/interfaces.py (1)

355-366: ⚠️ Potential issue | 🟠 Major

Keep render_librenms_id() read-only.

get_librenms_device_id() normalizes string-backed values by calling save() in utils.py, so using it from a table renderer turns a normal GET into a write path for each affected interface row. That can fire change logging/signals during page render; use a non-mutating lookup here and leave normalization to explicit write/migration paths.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@netbox_librenms_plugin/tables/interfaces.py` around lines 355 - 366,
render_librenms_id currently calls get_librenms_device_id(netbox_interface,
self.server_key) which performs a save/normalization and turns a read into a
write; change the renderer to use a non-mutating lookup instead (e.g. read the
raw attribute like netbox_interface.librenms_id or a precomputed mapping on the
table/context) and do not call get_librenms_device_id here; leave normalization
to explicit write paths or a dedicated non-saving helper (create/use a
get_librenms_device_id_readonly if needed) so rendering does not trigger
saves/signals.
♻️ Duplicate comments (3)
netbox_librenms_plugin/tests/test_librenms_id.py (1)

130-141: 🧹 Nitpick | 🔵 Trivial

Assert the default-key predicate here, not just the call count.

This still passes if find_by_librenms_id() stops defaulting to "default" and builds the Q for some other key. Inspect the constructed predicate here the same way as test_queries_server_key_and_legacy_integer() so the fallback behavior is actually covered.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@netbox_librenms_plugin/tests/test_librenms_id.py` around lines 130 - 141, The
test test_default_server_key_is_default currently only asserts that
mock_model.objects.filter was called; update it to assert the actual predicate
used so the fallback to the "default" server key is verified: after calling
find_by_librenms_id(mock_model, 42) inspect mock_model.objects.filter.call_args
and assert it was called with a Q that matches the same structure used in
test_queries_server_key_and_legacy_integer but using the default key string
"default" (i.e., ensure the Q contains the "default" key path and value 42
rather than any other key); use the same technique/assertion used in
test_queries_server_key_and_legacy_integer to compare the constructed predicate.
netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html (1)

74-89: ⚠️ Potential issue | 🟠 Major

VM stale mappings still have no cleanup action.

The backend now accepts object_type="vm", but this branch only renders the remove form for devices. VM sync pages will still show orphaned mappings as “Not configured” with no supported way to delete them; render the same action for virtual machines and include the object type in the POST payload.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html`
around lines 74 - 89, The template only renders the "Remove" form when
model_name == "device", leaving VM stale mappings without a cleanup action;
update the conditional in librenms_sync_base.html to also allow model_name ==
"vm" (or check inclusion in ["device","vm"]) so the same form/action (the form
posting to the 'plugins:netbox_librenms_plugin:remove_server_mapping' view) is
rendered for VMs, and add a hidden input named object_type with value="{{
model_name }}" alongside the existing hidden server_key so the POST payload
includes the object type for backend handling; keep the existing confirm text
and CSRF token.
netbox_librenms_plugin/tests/test_sync_view_mismatch.py (1)

331-416: 🧹 Nitpick | 🔵 Trivial

Reuse the shared config/object fixtures in these mapping tests.

These cases still hand-roll PLUGINS_CONFIG payloads and mock NetBox objects, which will drift as the multi-server config shape evolves. Replace _make_obj() and the inline config dicts with the fixtures from tests/conftest.py.

Based on learnings: Reuse fixtures from tests/conftest.py instead of creating ad-hoc mocks: Configuration (mock_multi_server_config, mock_legacy_config) and NetBox objects (mock_netbox_device, mock_netbox_vm, ...).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@netbox_librenms_plugin/tests/test_sync_view_mismatch.py` around lines 331 -
416, Tests in test_sync_view_mismatch.py are creating ad-hoc PLUGINS_CONFIG
dicts and MagicMock NetBox objects via _make_obj() instead of using shared
fixtures; update the tests (functions test_returns_none_for_legacy_int,
test_returns_none_for_missing_cf, test_single_configured_server,
test_orphaned_server_is_not_configured,
test_multiple_servers_sorted_active_first) to use the existing fixtures from
tests/conftest.py (e.g., mock_multi_server_config, mock_legacy_config,
mock_netbox_device, mock_netbox_vm) by removing _make_obj() usage and replacing
inline plugins_cfg mocks with the appropriate fixture injection and patching of
django_settings.PLUGINS_CONFIG so the tests rely on the shared fixtures rather
than hand-rolled data.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_validation_details.html`:
- Around line 405-427: The validation result from validate_device_for_import()
is missing the librenms_id_needs_migration flag; update
validate_device_for_import to compare the incoming device's LibreNMS identifier
format against the existing_device's stored LibreNMS identifier (e.g., compare
libre_device.device_id / incoming.librenms_id or their normalized forms) and set
validation.librenms_id_needs_migration = True when formats differ (and include
validation.existing_device = existing_device if not already set); ensure the
logic respects serial_confirmed flow so the template's checkbox/button behavior
(migrate-btn and force-migrate-{{ libre_device.device_id }}) works as intended.

In
`@netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html`:
- Around line 604-611: The Modules tab currently includes inc/_module_sync.html
directly (tied to the modules-tab button and `#modules` tab pane) which bypasses
the standard two-template HTMX pattern; create two templates named following the
convention (e.g. _module_sync.html and _module_sync_content.html) and update the
tab button and pane to include the wrapper template (the _module_sync.html that
mirrors _resource_sync.html) while the content pane should load/refresh the
_module_sync_content.html via HTMX just like other resources; ensure the tab
id/data-tab "modules" and target "#modules" remain the same so HTMX refresh
behavior matches the existing _resource_sync/_resource_sync_content flow.

In `@netbox_librenms_plugin/utils.py`:
- Around line 476-502: get_librenms_device_id currently performs side-effect
writes (obj.custom_field_data assignment + obj.save()) while doing reads; change
it to avoid automatic persistence by adding an optional parameter (e.g.,
auto_save=True) or by returning a tuple (device_id, needs_save) instead of
writing directly — detect normalized values the same way (string -> int,
dict[server_key] normalization) but if normalization is needed set
needs_save=True and update the in-memory custom_field_data without calling
obj.save(); callers (e.g., the table renderer in tables/interfaces.py) should be
updated to pass auto_save=False or to call obj.save() only when they intend to
persist changes so reads no longer trigger DB writes or signals.

In `@netbox_librenms_plugin/views/imports/actions.py`:
- Line 782: The context key "existing_id_servers" is always None because
validate_device_for_import() never sets it; either update
validate_device_for_import() in netbox_librenms_plugin/import_utils.py to
populate validation["existing_id_servers"] (e.g., derive per-server IDs when an
existing_device is found) or, alternatively, compute and set
context["existing_id_servers"] here in imports/actions.py from
validation["existing_device"] before rendering the template so the per-server
mapping badges can render; locate validate_device_for_import(), the validation
dict, and the use of context["existing_id_servers"] to implement the fix.

In `@netbox_librenms_plugin/views/object_sync/devices.py`:
- Around line 82-97: The code threads server_key into
VCInterfaceTable/LibreNMSInterfaceTable but the cache access in
SingleInterfaceVerifyView and SaveVlanGroupOverridesView still uses
get_cache_key(..., "ports") without the server scope, causing cross-server cache
collisions; update those flows to accept and pass the same server_key (or append
it to the cache suffix) when calling get_cache_key so the cache key becomes
server-scoped (e.g., get_cache_key(..., "ports", server_key) or
get_cache_key(..., f"ports:{server_key}") ), and ensure
SingleInterfaceVerifyView and SaveVlanGroupOverridesView propagate server_key
from the request/context into their cache get/set calls to match the table
layer.

In `@netbox_librenms_plugin/views/object_sync/vms.py`:
- Around line 46-50: The get_table method is not forwarding the resolved
interface_name_field into the LibreNMSVMInterfaceTable constructor, causing VM
sync to fall back to the default field; update get_table (method name get_table
in vms.py) to pass the interface_name_field argument through to
LibreNMSVMInterfaceTable so it receives the same interface_name_field used
earlier (LibreNMSVMInterfaceTable inherits row/accessor behavior from
LibreNMSInterfaceTable and needs this parameter to match ifDescr-based
installs).

In `@netbox_librenms_plugin/views/sync/device_fields.py`:
- Around line 409-425: The POST handler currently checks permissions against
both Device and VirtualMachine; before calling require_all_permissions()
determine the target object_type (e.g., read object_type from the request
payload/POST parameter or URL like how _get_object expects it), then set
self.required_object_permissions['POST'] = [("change", Device)] or [("change",
VirtualMachine)] for that request, and only then call require_all_permissions();
this scopes the required permission to the specific model (use symbols
required_object_permissions, post, _get_object, and require_all_permissions to
locate and implement the change).

---

Outside diff comments:
In `@netbox_librenms_plugin/import_utils.py`:
- Around line 1327-1335: The new-import path is storing librenms_id as a single
int in device_data (custom_field_data={"librenms_id": int(device_id)}) which
loses the server_key; update the creation flow to persist the mapping via
set_librenms_device_id(server_key, int(device_id)) before saving the Device (and
likewise for the VM creation path), and remove/replace direct single-int
assignments so that both Device and VM use the set_librenms_device_id(...) call
to record the id+server_key mapping rather than writing the legacy single-int
custom field.
- Around line 780-806: The existing lookup uses raw integer custom-field filters
(VirtualMachine.objects.filter(custom_field_data__librenms_id=int(librenms_id))
and Device.objects.filter(...)) which misses migrated per-server mappings;
replace those filters with the multi-server helper
find_by_librenms_id(librenms_id, server_key=server_key) (or the project’s
equivalent) to return the existing VM/Device, so both legacy ints and
{"server_key": id} shapes are honored; remove the int(...) conversion/ValueError
handling around those filters and assign the helper result to existing_vm and
existing_device respectively, keeping the subsequent result[...] assignments and
name-matching logic unchanged.

In `@netbox_librenms_plugin/tables/interfaces.py`:
- Around line 355-366: render_librenms_id currently calls
get_librenms_device_id(netbox_interface, self.server_key) which performs a
save/normalization and turns a read into a write; change the renderer to use a
non-mutating lookup instead (e.g. read the raw attribute like
netbox_interface.librenms_id or a precomputed mapping on the table/context) and
do not call get_librenms_device_id here; leave normalization to explicit write
paths or a dedicated non-saving helper (create/use a
get_librenms_device_id_readonly if needed) so rendering does not trigger
saves/signals.

---

Duplicate comments:
In
`@netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html`:
- Around line 74-89: The template only renders the "Remove" form when model_name
== "device", leaving VM stale mappings without a cleanup action; update the
conditional in librenms_sync_base.html to also allow model_name == "vm" (or
check inclusion in ["device","vm"]) so the same form/action (the form posting to
the 'plugins:netbox_librenms_plugin:remove_server_mapping' view) is rendered for
VMs, and add a hidden input named object_type with value="{{ model_name }}"
alongside the existing hidden server_key so the POST payload includes the object
type for backend handling; keep the existing confirm text and CSRF token.

In `@netbox_librenms_plugin/tests/test_librenms_id.py`:
- Around line 130-141: The test test_default_server_key_is_default currently
only asserts that mock_model.objects.filter was called; update it to assert the
actual predicate used so the fallback to the "default" server key is verified:
after calling find_by_librenms_id(mock_model, 42) inspect
mock_model.objects.filter.call_args and assert it was called with a Q that
matches the same structure used in test_queries_server_key_and_legacy_integer
but using the default key string "default" (i.e., ensure the Q contains the
"default" key path and value 42 rather than any other key); use the same
technique/assertion used in test_queries_server_key_and_legacy_integer to
compare the constructed predicate.

In `@netbox_librenms_plugin/tests/test_sync_view_mismatch.py`:
- Around line 331-416: Tests in test_sync_view_mismatch.py are creating ad-hoc
PLUGINS_CONFIG dicts and MagicMock NetBox objects via _make_obj() instead of
using shared fixtures; update the tests (functions
test_returns_none_for_legacy_int, test_returns_none_for_missing_cf,
test_single_configured_server, test_orphaned_server_is_not_configured,
test_multiple_servers_sorted_active_first) to use the existing fixtures from
tests/conftest.py (e.g., mock_multi_server_config, mock_legacy_config,
mock_netbox_device, mock_netbox_vm) by removing _make_obj() usage and replacing
inline plugins_cfg mocks with the appropriate fixture injection and patching of
django_settings.PLUGINS_CONFIG so the tests rely on the shared fixtures rather
than hand-rolled data.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI (base), Organization UI (inherited)

Review profile: ASSERTIVE

Plan: Pro

Run ID: ec9576f6-9b3d-4fec-8a5b-090451baa69a

📥 Commits

Reviewing files that changed from the base of the PR and between 906ee8c and c56ac4c.

📒 Files selected for processing (23)
  • netbox_librenms_plugin/forms.py
  • netbox_librenms_plugin/import_utils.py
  • netbox_librenms_plugin/librenms_api.py
  • netbox_librenms_plugin/static/netbox_librenms_plugin/js/librenms_import.js
  • netbox_librenms_plugin/tables/device_status.py
  • netbox_librenms_plugin/tables/interfaces.py
  • netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_validation_details.html
  • netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html
  • netbox_librenms_plugin/tests/test_librenms_id.py
  • netbox_librenms_plugin/tests/test_mixins.py
  • netbox_librenms_plugin/tests/test_sync_devices.py
  • netbox_librenms_plugin/tests/test_sync_interfaces.py
  • netbox_librenms_plugin/tests/test_sync_view_mismatch.py
  • netbox_librenms_plugin/urls.py
  • netbox_librenms_plugin/utils.py
  • netbox_librenms_plugin/views/__init__.py
  • netbox_librenms_plugin/views/base/librenms_sync_view.py
  • netbox_librenms_plugin/views/imports/actions.py
  • netbox_librenms_plugin/views/mixins.py
  • netbox_librenms_plugin/views/object_sync/devices.py
  • netbox_librenms_plugin/views/object_sync/vms.py
  • netbox_librenms_plugin/views/sync/device_fields.py
  • netbox_librenms_plugin/views/sync/interfaces.py

Comment thread netbox_librenms_plugin/forms.py
Comment on lines +604 to +611
{% if module_sync %}
<li class="nav-item" role="presentation">
<button class="nav-link" id="modules-tab" data-tab="modules" data-bs-toggle="tab"
data-bs-target="#modules" type="button" role="tab" aria-controls="modules">
Modules
</button>
</li>
{% endif %}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Keep the new Modules tab on the standard sync-template pair.

Including inc/_module_sync.html directly bypasses the _resource_sync.html / _resource_sync_content.html pattern the other tabs use for HTMX refreshes. Please give Modules the same wrapper/content split so it behaves like the other sync resources.

As per coding guidelines, "Each sync resource has two templates following a naming convention: _resource_sync.html and _resource_sync_content.html ... When adding a new sync resource, create both templates following this pattern."

Also applies to: 641-645

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html`
around lines 604 - 611, The Modules tab currently includes inc/_module_sync.html
directly (tied to the modules-tab button and `#modules` tab pane) which bypasses
the standard two-template HTMX pattern; create two templates named following the
convention (e.g. _module_sync.html and _module_sync_content.html) and update the
tab button and pane to include the wrapper template (the _module_sync.html that
mirrors _resource_sync.html) while the content pane should load/refresh the
_module_sync_content.html via HTMX just like other resources; ensure the tab
id/data-tab "modules" and target "#modules" remain the same so HTMX refresh
behavior matches the existing _resource_sync/_resource_sync_content flow.

Comment thread netbox_librenms_plugin/utils.py
Comment thread netbox_librenms_plugin/views/imports/actions.py Outdated
Comment thread netbox_librenms_plugin/views/object_sync/devices.py
Comment thread netbox_librenms_plugin/views/object_sync/vms.py
Comment thread netbox_librenms_plugin/views/sync/device_fields.py
@marcinpsk
marcinpsk force-pushed the pr/librenms-id-multi-server branch from c56ac4c to a9b956e Compare March 6, 2026 11:49

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
netbox_librenms_plugin/views/object_sync/devices.py (1)

343-362: ⚠️ Potential issue | 🟠 Major

Scope VLAN override cache entries by server_key too.

ports is now keyed per LibreNMS server, but the override map is still loaded and saved through the shared get_vlan_overrides_key(device) key. If the same NetBox device is synced against two servers, saving overrides from one server will leak into the other.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@netbox_librenms_plugin/views/object_sync/devices.py` around lines 343 - 362,
The VLAN override map is currently stored under a device-only key and leaks
between LibreNMS servers; update the overrides cache key to include the
server_key (use the same per-server scope as ports) by changing
get_vlan_overrides_key to accept a server_key parameter (or add a new method)
and then call cache.get(self.get_vlan_overrides_key(device, server_key)) and
cache.set(self.get_vlan_overrides_key(device, server_key), existing,
timeout=ports_ttl) in the block that reads/updates overrides; also update any
other callers of get_vlan_overrides_key to pass server_key so overrides are
isolated per server.
netbox_librenms_plugin/import_utils.py (2)

453-456: ⚠️ Potential issue | 🟠 Major

Normalize server_key from the provided API before building the import cache key.

process_device_filters() passes an api instance but no explicit server_key, so this key becomes librenms_devices_import_None_... for every server. That defeats the per-server namespace and can return another server’s device list from cache.

🛠️ Suggested fix
         if api is None:
             api = LibreNMSAPI(server_key=server_key)
+        server_key = server_key or getattr(api, "server_key", "default")
 
         # Build LibreNMS API filters using the type/query format
         # LibreNMS API v0 expects ?type=X&query=Y format, not direct parameters
@@
-        cache_key = f"librenms_devices_import_{server_key}_{hash(str(api_filters))}_{hash(str(client_filters))}"
+        cache_key = (
+            f"librenms_devices_import_{server_key}_{hash(str(api_filters))}_{hash(str(client_filters))}"
+        )

Also applies to: 552-554

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@netbox_librenms_plugin/import_utils.py` around lines 453 - 456,
process_device_filters builds the import cache key using the server_key
parameter which can be None when an api instance is passed, causing cache
collisions; before constructing the cache key, normalize server_key by
extracting it from the provided api (e.g., read api.server_key or an accessor on
the LibreNMSAPI instance) when api is not None, and use that normalized value in
the import cache key construction; apply the same fix in the other occurrence
that builds the cache key (the block around the second LibreNMSAPI creation) so
both places use the api-derived server_key when api is provided.

1777-1785: ⚠️ Potential issue | 🔴 Critical

New VM imports are still written in the legacy bare-int format.

This path never records which LibreNMS server the VM came from, so two servers that both have device_id=42 will still collide on the same VM link. It also bypasses the migration helpers added in this PR. Please thread server_key/api.server_key into create_vm_from_librenms() and persist the mapping via set_librenms_device_id() after creation.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@netbox_librenms_plugin/import_utils.py` around lines 1777 - 1785, The VM
creation currently stores a bare int librenms_id and doesn't record which
LibreNMS server it came from; update create_vm_from_librenms to accept a
server_key (or thread api.server_key into its call), stop writing raw ints into
custom_field_data, and after creating the VirtualMachine (the vm returned from
VirtualMachine.objects.create) call set_librenms_device_id(vm, api.server_key,
libre_device["device_id"]) to persist the server-scoped mapping using the
migration helper so IDs are namespaced by server.
♻️ Duplicate comments (3)
netbox_librenms_plugin/forms.py (1)

582-593: 🧹 Nitpick | 🔵 Trivial

Consider lazy server_key resolution to improve resilience.

The current implementation instantiates LibreNMSAPI() before the cache lookup (line 584). If the selected server is misconfigured, the __init__ will raise an exception before checking the cache, preventing use of cached location choices.

A more resilient pattern would resolve server_key cheaply first (e.g., from LibreNMSSettings.objects.first().selected_server), attempt the cache lookup, and only instantiate the full API client on a cache miss.

That said, this is a minor edge case—on API init failure, the form already falls back to default empty choices, so functionality isn't broken.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@netbox_librenms_plugin/forms.py` around lines 582 - 593, The code
instantiates LibreNMSAPI() before checking cache, so a misconfigured server can
raise in __init__ and prevent using cached choices; instead, first resolve the
cheap server_key (e.g., read LibreNMSSettings.objects.first().selected_server or
similar) and build the cache_key from that to check cache.get(cache_key) before
creating a LibreNMSAPI instance, and only instantiate LibreNMSAPI() and call
api.get_locations() on a cache miss; update references to cache_key,
LibreNMSAPI, server_key, LibreNMSSettings, get_locations, and
self.fields["librenms_location"].choices accordingly.
netbox_librenms_plugin/tests/test_sync_view_mismatch.py (1)

331-429: 🧹 Nitpick | 🔵 Trivial

Reuse the shared config/object fixtures in this mapping test suite.

These cases still hand-roll both PLUGINS_CONFIG payloads and NetBox objects even though the suite already provides dedicated multi-server config and object fixtures. Reusing those fixtures will keep these assertions aligned if the server-config shape changes again.

Based on learnings: Reuse fixtures from tests/conftest.py instead of creating ad-hoc mocks: Configuration (mock_multi_server_config, mock_legacy_config), API client (mock_librenms_api), NetBox objects (mock_netbox_device, mock_netbox_vm, mock_netbox_site, mock_netbox_platform, mock_netbox_device_type, mock_netbox_device_role, mock_netbox_cluster, mock_netbox_rack), HTTP responses (mock_response_factory, mock_success_response, mock_device_response, mock_error_response, mock_auth_error_response), and Import workflow (sample_librenms_device, sample_librenms_device_minimal, sample_validation_state, sample_validation_state_vm).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@netbox_librenms_plugin/tests/test_sync_view_mismatch.py` around lines 331 -
429, The tests in this file manually construct PLUGINS_CONFIG and NetBox mock
objects; change each test (e.g., test_single_configured_server,
test_orphaned_server_is_not_configured,
test_multiple_servers_sorted_active_first) to reuse the shared fixtures from
tests/conftest.py instead of hand-rolling values: inject
mock_multi_server_config or mock_legacy_config for PLUGINS_CONFIG and use the
provided mock_netbox_* fixtures (e.g., mock_netbox_device, mock_netbox_vm,
mock_netbox_site, mock_netbox_platform, mock_netbox_device_type,
mock_netbox_device_role, mock_netbox_cluster, mock_netbox_rack) for the obj
passed to BaseLibreNMSSyncView._build_all_server_mappings; where the tests
currently patch django_settings.PLUGINS_CONFIG, replace that patch with the
fixture-provided config and adjust assertions to rely on the fixture shapes
(keeping server keys like "production" and "mock-dev" consistent with
mock_multi_server_config).
netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html (1)

605-612: ⚠️ Potential issue | 🟠 Major

Keep Modules on the standard sync wrapper/content pair.

The Modules tab still includes inc/_module_sync.html directly, so it bypasses the same HTMX refresh contract the other sync resources use. Please add _module_sync.html / _module_sync_content.html and include the wrapper here instead.

As per coding guidelines, "Each sync resource has two templates following a naming convention: _resource_sync.html and _resource_sync_content.html ... When adding a new sync resource, create both templates following this pattern."

Also applies to: 642-645

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html`
around lines 605 - 612, The Modules tab currently includes the content partial
directly (inc/_module_sync.html) and bypasses the standard HTMX wrapper/content
contract; create two templates named _module_sync.html (wrapper) and
_module_sync_content.html (inner content) following the existing
_<resource>_sync.html and _<resource>_sync_content.html pattern, move the
current inc/_module_sync.html content into the new _module_sync_content.html,
and update the tab markup (the block using module_sync and the Modules button)
to include the wrapper template (_module_sync.html) instead of including the
content directly; also apply the same change to the other occurrence referenced
around lines 642-645 so both places use the wrapper/content pair.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@netbox_librenms_plugin/import_utils.py`:
- Around line 782-787: The code incorrectly forces server_key = "default" when
api is None, which causes find_by_librenms_id(VirtualMachine, librenms_id,
server_key) to search the wrong server; change the logic so server_key is None
when api is None (e.g. server_key = api.server_key if api is not None else None)
and propagate that None into find_by_librenms_id so callers that skip VC
detection (api=None) do not get remapped to the "default" server; make the same
change for the other occurrence around import_single_device() (lines ~810-812)
to preserve correct server scoping.

In `@netbox_librenms_plugin/tests/test_mixins.py`:
- Around line 155-181: Update the tests to assert exact cache-key strings rather
than substrings: for get_last_fetched_key(obj, "ports") assert it equals
"librenms_ports_last_fetched_device_3" and for the server-scoped call
get_last_fetched_key(obj, "ports", server_key="srv1") assert it equals
"librenms_ports_last_fetched_srv1_device_3"; also add an assertion that
get_vlan_overrides_key(obj) exists and returns
"librenms_vlan_overrides_device_3" to lock the VLAN helper contract; locate and
change assertions around get_last_fetched_key and get_vlan_overrides_key in the
tests referencing those helper functions.

In `@netbox_librenms_plugin/tests/test_sync_interfaces.py`:
- Around line 6-15: The tests currently build ad-hoc mocks via helper functions
_make_view() and _make_device_interface(); instead, refactor those tests to use
the shared fixtures from tests/conftest.py (e.g. mock_librenms_api,
mock_multi_server_config, mock_legacy_config and the NetBox object fixtures) and
wire them into the test functions (inject fixtures into the test signature) so
SyncInterfacesView instances are created by using the fixture-provided
mock_librenms_api and NetBox objects rather than object.__new__ builders; update
any assertions that reference view._librenms_api.server_key or view._lookup_maps
to use the fixture state instead.

In `@netbox_librenms_plugin/utils.py`:
- Around line 483-507: The current custom-field normalization accepts a bare
int/string for librenms_id universally; update the logic in the function around
cf_value handling (referencing cf_value, server_key, obj.custom_field_data,
auto_save and obj.save()) so that the bare-integer fallback (returning cf_value
when isinstance(cf_value, int)) only applies when server_key equals the
default/legacy server identifier, and when reading JSON dicts or performing
lookups (used by find_by_librenms_id) treat both numeric and string forms as
equivalent by attempting int casts and also accepting string matches until
migration completes; ensure you still write back normalized ints into
obj.custom_field_data and save when auto_save is True.

In `@netbox_librenms_plugin/views/base/librenms_sync_view.py`:
- Line 120: The code passes the original `obj` into `_build_all_server_mappings`
causing badge/mapping mismatch for VC members; instead build
`all_server_mappings` from the same resolved lookup object used when computing
`self.librenms_id` (the object returned by the `get()`/lookup that may resolve
via `librenms_sync_device`) and pass that resolved object into
`_build_all_server_mappings` (replace `obj` with the resolved lookup object used
for `self.librenms_id`, keeping `self.librenms_api.server_key` unchanged).

In `@netbox_librenms_plugin/views/imports/actions.py`:
- Around line 1121-1131: The conflict-resolution flow currently reads/writes a
bare integer custom field directly; update the handler so all branches (link,
update, update_serial, and collision checks) use the server-aware accessors:
call self.librenms_api.get_librenms_id(existing_device) (and for candidates) to
read the per-server mapping, and use set_librenms_device_id(existing_device,
librenms_id, self.librenms_api.server_key) when writing instead of assigning
custom_field_data["librenms_id"] directly; also adjust the collision check to
look up devices by get_librenms_id() (or by querying the JSON mapping field for
the server_key) so conflicts on non-default servers are detected.

In `@netbox_librenms_plugin/views/sync/device_fields.py`:
- Around line 413-427: The view currently treats only "vm" as a VM and falls
back to Device for any other object_type, causing wrong permission checks and
lookups when the template posts "virtualmachine"; update normalization in post
(and where _get_object and _sync_url_name read object_type) to lower-case and
map "virtualmachine" to "vm" (or explicitly validate and reject unknown values)
before branching so target_model is set correctly (use a small conditional or
mapping to convert object_type -> "vm" or "device" and then choose
VirtualMachine vs Device accordingly, and ensure _get_object and _sync_url_name
use the same normalized value).

---

Outside diff comments:
In `@netbox_librenms_plugin/import_utils.py`:
- Around line 453-456: process_device_filters builds the import cache key using
the server_key parameter which can be None when an api instance is passed,
causing cache collisions; before constructing the cache key, normalize
server_key by extracting it from the provided api (e.g., read api.server_key or
an accessor on the LibreNMSAPI instance) when api is not None, and use that
normalized value in the import cache key construction; apply the same fix in the
other occurrence that builds the cache key (the block around the second
LibreNMSAPI creation) so both places use the api-derived server_key when api is
provided.
- Around line 1777-1785: The VM creation currently stores a bare int librenms_id
and doesn't record which LibreNMS server it came from; update
create_vm_from_librenms to accept a server_key (or thread api.server_key into
its call), stop writing raw ints into custom_field_data, and after creating the
VirtualMachine (the vm returned from VirtualMachine.objects.create) call
set_librenms_device_id(vm, api.server_key, libre_device["device_id"]) to persist
the server-scoped mapping using the migration helper so IDs are namespaced by
server.

In `@netbox_librenms_plugin/views/object_sync/devices.py`:
- Around line 343-362: The VLAN override map is currently stored under a
device-only key and leaks between LibreNMS servers; update the overrides cache
key to include the server_key (use the same per-server scope as ports) by
changing get_vlan_overrides_key to accept a server_key parameter (or add a new
method) and then call cache.get(self.get_vlan_overrides_key(device, server_key))
and cache.set(self.get_vlan_overrides_key(device, server_key), existing,
timeout=ports_ttl) in the block that reads/updates overrides; also update any
other callers of get_vlan_overrides_key to pass server_key so overrides are
isolated per server.

---

Duplicate comments:
In `@netbox_librenms_plugin/forms.py`:
- Around line 582-593: The code instantiates LibreNMSAPI() before checking
cache, so a misconfigured server can raise in __init__ and prevent using cached
choices; instead, first resolve the cheap server_key (e.g., read
LibreNMSSettings.objects.first().selected_server or similar) and build the
cache_key from that to check cache.get(cache_key) before creating a LibreNMSAPI
instance, and only instantiate LibreNMSAPI() and call api.get_locations() on a
cache miss; update references to cache_key, LibreNMSAPI, server_key,
LibreNMSSettings, get_locations, and self.fields["librenms_location"].choices
accordingly.

In
`@netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html`:
- Around line 605-612: The Modules tab currently includes the content partial
directly (inc/_module_sync.html) and bypasses the standard HTMX wrapper/content
contract; create two templates named _module_sync.html (wrapper) and
_module_sync_content.html (inner content) following the existing
_<resource>_sync.html and _<resource>_sync_content.html pattern, move the
current inc/_module_sync.html content into the new _module_sync_content.html,
and update the tab markup (the block using module_sync and the Modules button)
to include the wrapper template (_module_sync.html) instead of including the
content directly; also apply the same change to the other occurrence referenced
around lines 642-645 so both places use the wrapper/content pair.

In `@netbox_librenms_plugin/tests/test_sync_view_mismatch.py`:
- Around line 331-429: The tests in this file manually construct PLUGINS_CONFIG
and NetBox mock objects; change each test (e.g., test_single_configured_server,
test_orphaned_server_is_not_configured,
test_multiple_servers_sorted_active_first) to reuse the shared fixtures from
tests/conftest.py instead of hand-rolling values: inject
mock_multi_server_config or mock_legacy_config for PLUGINS_CONFIG and use the
provided mock_netbox_* fixtures (e.g., mock_netbox_device, mock_netbox_vm,
mock_netbox_site, mock_netbox_platform, mock_netbox_device_type,
mock_netbox_device_role, mock_netbox_cluster, mock_netbox_rack) for the obj
passed to BaseLibreNMSSyncView._build_all_server_mappings; where the tests
currently patch django_settings.PLUGINS_CONFIG, replace that patch with the
fixture-provided config and adjust assertions to rely on the fixture shapes
(keeping server keys like "production" and "mock-dev" consistent with
mock_multi_server_config).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI (base), Organization UI (inherited)

Review profile: ASSERTIVE

Plan: Pro

Run ID: d9f981b4-e860-4996-8a8c-eddf75538a2a

📥 Commits

Reviewing files that changed from the base of the PR and between c56ac4c and a9b956e.

📒 Files selected for processing (27)
  • netbox_librenms_plugin/forms.py
  • netbox_librenms_plugin/import_utils.py
  • netbox_librenms_plugin/librenms_api.py
  • netbox_librenms_plugin/static/netbox_librenms_plugin/js/librenms_import.js
  • netbox_librenms_plugin/static/netbox_librenms_plugin/js/librenms_sync.js
  • netbox_librenms_plugin/tables/device_status.py
  • netbox_librenms_plugin/tables/interfaces.py
  • netbox_librenms_plugin/templates/netbox_librenms_plugin/_interface_sync_content.html
  • netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_validation_details.html
  • netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html
  • netbox_librenms_plugin/tests/test_import_utils.py
  • netbox_librenms_plugin/tests/test_librenms_id.py
  • netbox_librenms_plugin/tests/test_mixins.py
  • netbox_librenms_plugin/tests/test_sync_devices.py
  • netbox_librenms_plugin/tests/test_sync_interfaces.py
  • netbox_librenms_plugin/tests/test_sync_view_mismatch.py
  • netbox_librenms_plugin/urls.py
  • netbox_librenms_plugin/utils.py
  • netbox_librenms_plugin/views/__init__.py
  • netbox_librenms_plugin/views/base/interfaces_view.py
  • netbox_librenms_plugin/views/base/librenms_sync_view.py
  • netbox_librenms_plugin/views/imports/actions.py
  • netbox_librenms_plugin/views/mixins.py
  • netbox_librenms_plugin/views/object_sync/devices.py
  • netbox_librenms_plugin/views/object_sync/vms.py
  • netbox_librenms_plugin/views/sync/device_fields.py
  • netbox_librenms_plugin/views/sync/interfaces.py

Comment thread netbox_librenms_plugin/import_utils.py Outdated
Comment thread netbox_librenms_plugin/tests/test_mixins.py Outdated
Comment thread netbox_librenms_plugin/tests/test_sync_devices.py
Comment on lines +6 to +15
def _make_view():
"""Return a SyncInterfacesView with a mocked LibreNMS API."""
from netbox_librenms_plugin.views.sync.interfaces import SyncInterfacesView

view = object.__new__(SyncInterfacesView)
view._librenms_api = MagicMock()
view._librenms_api.server_key = "default"
view.request = MagicMock()
view._lookup_maps = {}
return view

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Reuse the shared test fixtures instead of private mock builders.

_make_view() and _make_device_interface() rebuild API/object scaffolding that already lives in tests/conftest.py, which makes these tests easier to drift from the rest of the suite. Prefer wiring mock_librenms_api and the shared NetBox fixtures into the tests directly where possible.

Based on learnings, "Reuse fixtures from tests/conftest.py instead of creating ad-hoc mocks: Configuration (mock_multi_server_config, mock_legacy_config), API client (mock_librenms_api), NetBox objects ..."

Also applies to: 21-44

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@netbox_librenms_plugin/tests/test_sync_interfaces.py` around lines 6 - 15,
The tests currently build ad-hoc mocks via helper functions _make_view() and
_make_device_interface(); instead, refactor those tests to use the shared
fixtures from tests/conftest.py (e.g. mock_librenms_api,
mock_multi_server_config, mock_legacy_config and the NetBox object fixtures) and
wire them into the test functions (inject fixtures into the test signature) so
SyncInterfacesView instances are created by using the fixture-provided
mock_librenms_api and NetBox objects rather than object.__new__ builders; update
any assertions that reference view._librenms_api.server_key or view._lookup_maps
to use the fixture state instead.

Comment thread netbox_librenms_plugin/utils.py Outdated
Comment thread netbox_librenms_plugin/views/base/librenms_sync_view.py Outdated
Comment thread netbox_librenms_plugin/views/sync/device_fields.py
@marcinpsk
marcinpsk force-pushed the pr/librenms-id-multi-server branch from a9b956e to 24998bd Compare March 6, 2026 12:46

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
netbox_librenms_plugin/static/netbox_librenms_plugin/js/librenms_sync.js (1)

783-811: ⚠️ Potential issue | 🟠 Major

Handle HTTP failures before parsing the verify-interface response.

handleInterfaceChange() still calls response.json() unconditionally and never catches failures. A 403/500 response from the now server-scoped endpoint will reject the promise and leave the row stale with no useful diagnostic.

Suggested fix
     fetch('/plugins/librenms_plugin/verify-interface/', {
         method: 'POST',
         headers: {
             'Content-Type': 'application/json',
             'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]').value
         },
         body: JSON.stringify({
             device_id: value,
             interface_name: select.dataset.interface,
             interface_name_field: document.querySelector('input[name="interface_name_field"]:checked').value,
             server_key: document.getElementById('current-server-key')?.value || null
         })
     })
-        .then(response => response.json())
+        .then(response => {
+            if (!response.ok) {
+                throw new Error(`HTTP ${response.status}`);
+            }
+            return response.json();
+        })
         .then(data => {
             const row = document.querySelector(`tr[data-interface="${select.dataset.rowId}"]`);
             if (data.status === 'success' && row) {
                 const formattedRow = data.formatted_row;
                 row.querySelector('td[data-col="name"]').innerHTML = formattedRow.name;
@@
                 row.querySelector('td[data-col="description"]').innerHTML = formattedRow.description;
                 initializeFilters();
             }
-        });
+        })
+        .catch(error => {
+            console.error('Interface verification failed:', error.message);
+        });

As per coding guidelines, netbox_librenms_plugin/static/**/*.js: Always check response.ok before processing fetch responses to catch HTTP errors. In catch blocks, show error.message for debugging rather than generic messages.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@netbox_librenms_plugin/static/netbox_librenms_plugin/js/librenms_sync.js`
around lines 783 - 811, The handleInterfaceChange function currently calls
response.json() without checking HTTP status; update handleInterfaceChange to
check response.ok after the fetch (when calling the verify-interface endpoint)
and handle non-OK responses by reading response.text() or response.json() for
diagnostics, then surface the error (use error.message or the response body) and
avoid updating the row; keep the success path that reads data.formatted_row and
calls initializeFilters() but add a .catch that logs/shows error.message so
failures (403/500) don’t silently leave the row stale; reference symbols:
handleInterfaceChange, response.ok, response.json(), initializeFilters(), and
the verify-interface POST.
netbox_librenms_plugin/views/base/librenms_sync_view.py (1)

33-44: ⚠️ Potential issue | 🟠 Major

Make the VC lookup server-aware.

This branch still uses obj.cf.get("librenms_id") as a yes/no check, so a member that only has a mapping for some other server will block fallback to the VC sync device on the current server. The new _librenms_lookup_device then carries that wrong choice through the whole page.

🔧 Suggested fix
         librenms_lookup_device = obj
         if hasattr(obj, "virtual_chassis") and obj.virtual_chassis:
-            # Check if this device has its own librenms_id
-            if not obj.cf.get("librenms_id"):
+            # Check the mapping for the active server only.
+            if not self.librenms_api.get_librenms_id(obj):
                 # Use helper function to determine the sync device
                 sync_device = get_librenms_sync_device(obj)
                 if sync_device:
                     librenms_lookup_device = sync_device

As per coding guidelines, Use LibreNMSAPI.get_librenms_id() instead of directly accessing the librenms_id custom field when mapping Devices/VMs to LibreNMS.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@netbox_librenms_plugin/views/base/librenms_sync_view.py` around lines 33 -
44, The current VC branch checks obj.cf.get("librenms_id") which is not
server-aware and can block falling back to the VC sync device; instead call the
API to determine if this device maps for the active server. Replace the
obj.cf.get(...) check with a call to self.librenms_api.get_librenms_id(obj) and
if that returns falsy, call get_librenms_sync_device(obj) and then call
self.librenms_api.get_librenms_id on the sync device; finally assign
self.librenms_id from the API result and set self._librenms_lookup_device to
whichever device was actually used (obj or sync_device) so subsequent logic uses
the server-aware mapping.
♻️ Duplicate comments (8)
netbox_librenms_plugin/utils.py (1)

456-507: ⚠️ Potential issue | 🟠 Major

Keep legacy scalar IDs scoped to the default server only.

Bare librenms_id scalars are still treated as valid for every server_key, and find_by_librenms_id() still ignores string-stored values like "42". In a multi-server install that lets a legacy ID from one server satisfy lookups for another server, while some existing rows remain undiscoverable until an unrelated write normalizes them.

Suggested fix
 def get_librenms_device_id(obj, server_key: str = "default", *, auto_save: bool = True):
@@
-    if isinstance(cf_value, int):
-        return cf_value  # backward compat: bare integer from pre-migration
+    if isinstance(cf_value, int):
+        return cf_value if server_key == "default" else None
     if isinstance(cf_value, str):
+        if server_key != "default":
+            return None
         # Someone stored a bare string (e.g., via NetBox UI/API) — normalise to int.
         try:
             int_id = int(cf_value)
@@
 def find_by_librenms_id(model, librenms_id, server_key: str = "default"):
@@
-    return model.objects.filter(
-        Q(**{f"custom_field_data__librenms_id__{server_key}": librenms_id})
-        | Q(custom_field_data__librenms_id=librenms_id)
-    ).first()
+    try:
+        numeric_id = int(librenms_id)
+    except (TypeError, ValueError):
+        return None
+
+    query = (
+        Q(**{f"custom_field_data__librenms_id__{server_key}": numeric_id})
+        | Q(**{f"custom_field_data__librenms_id__{server_key}": str(numeric_id)})
+    )
+    if server_key == "default":
+        query |= (
+            Q(custom_field_data__librenms_id=numeric_id)
+            | Q(custom_field_data__librenms_id=str(numeric_id))
+        )
+    return model.objects.filter(query).first()

Also applies to: 544-563

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@netbox_librenms_plugin/utils.py` around lines 456 - 507, The current
get_librenms_device_id treats legacy scalar librenms_id values as valid for any
server_key and leaves find_by_librenms_id ignoring string IDs; change
get_librenms_device_id to scope bare integer scalars (and bare-string
normalisation) to the default server only by returning the scalar only when
server_key == "default" (or matches the configured default key), and when
cf_value is dict keep per-server behaviour and normalise string entries as
already implemented (update obj.custom_field_data and save when auto_save). Also
update the corresponding lookup function find_by_librenms_id to respect server
scoping (only match scalar IDs for the default server) and to consider
string-stored IDs by normalising/handling strings the same way as
get_librenms_device_id so rows with "42" are discoverable without requiring a
write.
netbox_librenms_plugin/views/imports/actions.py (1)

1121-1131: ⚠️ Potential issue | 🔴 Critical

Finish the server-aware librenms_id migration across the other conflict actions.

This new branch uses set_librenms_device_id(), but link, update, update_serial, and the collision check immediately above still read/write a bare integer. On non-default servers those paths can overwrite dict-backed mappings or miss conflicts, so the new action only fixes one branch of the flow.

Suggested fix
-        if action in {"link", "update", "update_serial"}:
-            id_conflict = (
-                Device.objects.filter(custom_field_data__librenms_id=int(librenms_id))
-                .exclude(pk=existing_device.pk)
-                .first()
-            )
+        if action in {"link", "update", "update_serial"}:
+            from netbox_librenms_plugin.utils import find_by_librenms_id, set_librenms_device_id
+
+            id_conflict = find_by_librenms_id(Device, librenms_id, self.librenms_api.server_key)
+            if id_conflict and id_conflict.pk == existing_device.pk:
+                id_conflict = None
@@
-            existing_device.custom_field_data["librenms_id"] = int(librenms_id)
+            set_librenms_device_id(existing_device, librenms_id, self.librenms_api.server_key)
@@
-            existing_device.custom_field_data["librenms_id"] = int(librenms_id)
+            set_librenms_device_id(existing_device, librenms_id, self.librenms_api.server_key)
@@
-            existing_device.custom_field_data["librenms_id"] = int(librenms_id)
+            set_librenms_device_id(existing_device, librenms_id, self.librenms_api.server_key)
Based on learnings: Use `LibreNMSAPI.get_librenms_id()` instead of directly accessing the `librenms_id` custom field when mapping Devices/VMs to LibreNMS.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@netbox_librenms_plugin/views/imports/actions.py` around lines 1121 - 1131,
The other branches ('link', 'update', 'update_serial' and the collision-check)
still read/write a bare integer librenms_id; update those paths to use the
server-aware accessors: call LibreNMSAPI.get_librenms_id(self.librenms_api,
existing_device) when reading the current mapping and use
set_librenms_device_id(existing_device, librenms_id,
self.librenms_api.server_key) when writing so dict-backed per-server IDs aren't
overwritten; also update any conflict-check logic to compare via get_librenms_id
and adjust logging/messages to show the per-server dict format consistently and
keep the _save_device(existing_device) usage after writes.
netbox_librenms_plugin/forms.py (1)

584-585: ⚠️ Potential issue | 🟡 Minor

Check the cache before constructing LibreNMSAPI().

A warm librenms_locations_choices:<server_key> cache is unusable if LibreNMSAPI() raises during server/config resolution, so the form drops back to the empty default even though cached choices exist. Resolve the active server key cheaply first, then build the API client only on a cache miss.

Suggested fix
         try:
             # Use caching to avoid repeated API calls
-            api = LibreNMSAPI()
-            cache_key = f"librenms_locations_choices:{api.server_key}"
+            settings = LibreNMSSettings.objects.first()
+            server_key = getattr(settings, "selected_server", "default") or "default"
+            cache_key = f"librenms_locations_choices:{server_key}"
             cached_choices = cache.get(cache_key)

             if cached_choices:
                 self.fields["librenms_location"].choices = cached_choices
                 return

+            api = LibreNMSAPI(server_key=server_key)
             # Fetch locations from LibreNMS
             success, locations = api.get_locations()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@netbox_librenms_plugin/forms.py` around lines 584 - 585, The code currently
constructs LibreNMSAPI() before checking cache, which can raise and prevent
using an existing librenms_locations_choices:<server_key> cache; change the flow
to first resolve the active server key cheaply (use whatever helper or config
lookup that yields api.server_key without instantiating LibreNMSAPI), build
cache_key = f"librenms_locations_choices:{server_key}", check the cache and
return cached choices if present, and only instantiate LibreNMSAPI() (and call
into it) when the cache misses so that exceptions during API construction don’t
discard valid cached choices.
netbox_librenms_plugin/import_utils.py (1)

641-641: ⚠️ Potential issue | 🔴 Critical

Don't let api-less validation silently fall back to the "default" server.

server_key still defaults to "default", so any caller that intentionally passes api=None keeps resolving existing links against the wrong LibreNMS server. In this module that means non-default imports can miss already-linked Devices/VMs whenever VC detection is skipped, which reopens duplicate-import paths.

Suggested fix
 def validate_device_for_import(
     libre_device: dict,
     import_as_vm: bool = False,
     api: "LibreNMSAPI" = None,
     *,
-    server_key: str = "default",
+    server_key: str | None = None,
     include_vc_detection: bool = True,
     force_vc_refresh: bool = False,
     use_sysname: bool = True,
     strip_domain: bool = False,
 ) -> dict:
@@
-        server_key = api.server_key if api is not None else server_key
+        resolved_server_key = api.server_key if api is not None else server_key
@@
-        existing_vm = find_by_librenms_id(VirtualMachine, librenms_id, server_key)
+        existing_vm = find_by_librenms_id(VirtualMachine, librenms_id, resolved_server_key)
@@
-            existing_device = find_by_librenms_id(Device, librenms_id, server_key)
+            existing_device = find_by_librenms_id(Device, librenms_id, resolved_server_key)
Pass the active `server_key` explicitly at the local call sites that disable VC detection, rather than relying on the default.

Also applies to: 783-788, 808-812

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@netbox_librenms_plugin/import_utils.py` at line 641, A default of server_key:
str = "default" causes callers that pass api=None to silently resolve against
the wrong LibreNMS server; remove the implicit fallback by making server_key
required (or Optional without "default") in the function signature(s) and update
each local call site that disables VC detection (the calls that pass api=None)
to explicitly forward the active server_key variable (e.g., call foo(...,
api=None, server_key=server_key)); ensure you update all similar call sites in
this module that disable VC detection so validation uses the correct server_key
instead of "default".
netbox_librenms_plugin/views/object_sync/devices.py (1)

129-145: ⚠️ Potential issue | 🟠 Major

Pass the same server-scoped table context into verify-row rendering.

The cache lookup is now keyed by server_key, but the temporary VCInterfaceTable/LibreNMSInterfaceTable built here still omits that context. On non-default servers the JSON response can render buttons/data attributes for the wrong server, and if the constructors now require server_key this path will fail outright. Reuse the same table-construction path as DeviceInterfaceTableView.get_table(), or pass the same server_key inputs here as well.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@netbox_librenms_plugin/views/object_sync/devices.py` around lines 129 - 145,
The cached lookup uses server_key but the temporary table here
(VCInterfaceTable/LibreNMSInterfaceTable) is constructed without the same
server-scoped context, causing wrong rendering or failure; update this branch to
build the table the same way as DeviceInterfaceTableView.get_table() or pass
server_key into the table constructor so the table instantiation for
selected_device includes the server_key (e.g., reuse
DeviceInterfaceTableView.get_table(...) or call VCInterfaceTable(...,
server_key=server_key) / LibreNMSInterfaceTable(..., server_key=server_key))
before calling format_interface_data(port_data, selected_device).
netbox_librenms_plugin/tests/test_sync_interfaces.py (1)

109-115: 🧹 Nitpick | 🔵 Trivial

Use a sentinel here too to prove description stayed untouched.

A fresh MagicMock already satisfies iface.description != "eth0", so this test still passes if the implementation writes None, "", or any other wrong value. Seed description with a unique object and assert identity after the update.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@netbox_librenms_plugin/tests/test_sync_interfaces.py` around lines 109 - 115,
Test uses a fresh MagicMock for iface.description so it passes even if code sets
None/empty; set iface.description to a unique sentinel object (e.g., object())
before calling view.update_interface_attributes and then assert that
iface.description is still that sentinel (identity check) after the call to
prove it was untouched; reference the test function using
view.update_interface_attributes and the iface.description attribute to locate
where to set the sentinel and change the assertion.
netbox_librenms_plugin/tests/test_librenms_id.py (1)

97-105: 🧹 Nitpick | 🔵 Trivial

Assert that the combined lookup is actually an OR.

These checks still pass if find_by_librenms_id() regresses to Q(custom_field_data__librenms_id__default=42, custom_field_data__librenms_id=42), which would miss the legacy/per-server fallback behavior. Assert q_arg.connector == "OR" and the child values too, not just the field names.

Also applies to: 147-155

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@netbox_librenms_plugin/tests/test_librenms_id.py` around lines 97 - 105, The
combined Q constructed in find_by_librenms_id must be asserted to be an OR and
to contain the expected child lookups and values: update the test after
retrieving call_args = mock_model.objects.filter.call_args to assert
isinstance(q_arg, Q) and add assertions q_arg.connector == "OR" and that each
child in q_arg.children is a tuple where the first element equals the expected
lookup names ("custom_field_data__librenms_id__default" and
"custom_field_data__librenms_id") and the second element equals the expected
librenms id value (e.g., 42); apply the same stricter assertions to the second
test block around lines 147-155 as well so regressions to a single combined
lookup are caught.
netbox_librenms_plugin/views/sync/device_fields.py (1)

423-429: ⚠️ Potential issue | 🟠 Major

Reject unknown object_type values instead of defaulting them to Device.

Any value other than "vm"/"virtualmachine" currently falls through the device branch. A stale or malformed form can therefore run the device permission check, lookup, and redirect path against the wrong record when PKs overlap. Validate the input against an explicit allowlist before choosing the target model.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@netbox_librenms_plugin/views/sync/device_fields.py` around lines 423 - 429,
The post method currently maps any non-"virtualmachine"/non-"vm" object_type to
Device, which can mis-route permissions/lookup; modify post to validate
request.POST["object_type"] against an explicit allowlist (e.g., {"device",
"vm", "virtualmachine"}) before setting target_model and
self.required_object_permissions, and reject unknown values with a clear failure
(raise BadRequest or return an HTTP 400) instead of defaulting to Device; update
the mapping logic that sets target_model (and the normalization from
"virtualmachine" -> "vm") so only allowed names select VirtualMachine or Device
and all other inputs are treated as invalid.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@netbox_librenms_plugin/import_utils.py`:
- Around line 1368-1372: The code in import_single_device() writes the
librenms_id mapping using the raw server_key arg (or "default") which can be
incorrect because LibreNMSAPI(server_key=server_key) may resolve a different
active server; update the write to use the resolved server key from the API
instance (e.g., use the LibreNMSAPI object's server_key or equivalent resolved
attribute) when calling set_librenms_device_id(device, device_id,
resolved_server_key) before device.save(), so the mapping is persisted under the
actual server used.

In `@netbox_librenms_plugin/tests/test_import_utils.py`:
- Around line 1345-1356: The helper _setup_librenms_id_match currently sets
filter.return_value.first.return_value for mock_device and mock_vm so every
Device.objects.filter(...).first() returns existing_device; change it to scope
the mock to only the librenms-id query by replacing the blanket return_value
with a side_effect on the filter call (or filter.return_value) that inspects
positional args for Q objects (the pattern used by find_by_librenms_id) and
returns a Mock whose first.return_value is existing_device only when those
positional Q args are present, otherwise return a Mock whose first.return_value
is None; update both mock_device.objects.filter and mock_vm.objects.filter to
use this scoped side_effect so hostname/serial matching paths are not affected.

In `@netbox_librenms_plugin/utils.py`:
- Around line 511-541: set_librenms_device_id currently treats string values
like "42" as unexpected and resets them, losing legacy mappings; update the
logic in set_librenms_device_id to recognize int-like strings (e.g., str of
digits or convertible to int) when reading cf_value and migrate them into the
dict form (cf_value = {"default": int(cf_value)}) instead of resetting to {};
likewise update migrate_legacy_librenms_id to handle int-like strings the same
way so an explicit migration will recover those values; refer to the cf_value
variable, server_key parameter, and the set_librenms_device_id and
migrate_legacy_librenms_id functions when making the changes.

In `@netbox_librenms_plugin/views/sync/device_fields.py`:
- Around line 442-445: The membership check treats only dict-backed custom-field
mappings as valid and ignores legacy bare-integer librenms_id values; normalize
cf_value before checks by detecting legacy types (e.g., int or string) and
converting them into the expected dict shape (for example mapping to a 'default'
key) or by calling the existing mapping helper used elsewhere; update the logic
where cf_value is read (the cf_value variable and its membership checks around
server_key in this file, including the shown block and the later block at lines
~473-480) so legacy ints are normalized prior to the isinstance(...) and
server_key membership checks so pre-migration mappings can be found and deleted.

---

Outside diff comments:
In `@netbox_librenms_plugin/static/netbox_librenms_plugin/js/librenms_sync.js`:
- Around line 783-811: The handleInterfaceChange function currently calls
response.json() without checking HTTP status; update handleInterfaceChange to
check response.ok after the fetch (when calling the verify-interface endpoint)
and handle non-OK responses by reading response.text() or response.json() for
diagnostics, then surface the error (use error.message or the response body) and
avoid updating the row; keep the success path that reads data.formatted_row and
calls initializeFilters() but add a .catch that logs/shows error.message so
failures (403/500) don’t silently leave the row stale; reference symbols:
handleInterfaceChange, response.ok, response.json(), initializeFilters(), and
the verify-interface POST.

In `@netbox_librenms_plugin/views/base/librenms_sync_view.py`:
- Around line 33-44: The current VC branch checks obj.cf.get("librenms_id")
which is not server-aware and can block falling back to the VC sync device;
instead call the API to determine if this device maps for the active server.
Replace the obj.cf.get(...) check with a call to
self.librenms_api.get_librenms_id(obj) and if that returns falsy, call
get_librenms_sync_device(obj) and then call self.librenms_api.get_librenms_id on
the sync device; finally assign self.librenms_id from the API result and set
self._librenms_lookup_device to whichever device was actually used (obj or
sync_device) so subsequent logic uses the server-aware mapping.

---

Duplicate comments:
In `@netbox_librenms_plugin/forms.py`:
- Around line 584-585: The code currently constructs LibreNMSAPI() before
checking cache, which can raise and prevent using an existing
librenms_locations_choices:<server_key> cache; change the flow to first resolve
the active server key cheaply (use whatever helper or config lookup that yields
api.server_key without instantiating LibreNMSAPI), build cache_key =
f"librenms_locations_choices:{server_key}", check the cache and return cached
choices if present, and only instantiate LibreNMSAPI() (and call into it) when
the cache misses so that exceptions during API construction don’t discard valid
cached choices.

In `@netbox_librenms_plugin/import_utils.py`:
- Line 641: A default of server_key: str = "default" causes callers that pass
api=None to silently resolve against the wrong LibreNMS server; remove the
implicit fallback by making server_key required (or Optional without "default")
in the function signature(s) and update each local call site that disables VC
detection (the calls that pass api=None) to explicitly forward the active
server_key variable (e.g., call foo(..., api=None, server_key=server_key));
ensure you update all similar call sites in this module that disable VC
detection so validation uses the correct server_key instead of "default".

In `@netbox_librenms_plugin/tests/test_librenms_id.py`:
- Around line 97-105: The combined Q constructed in find_by_librenms_id must be
asserted to be an OR and to contain the expected child lookups and values:
update the test after retrieving call_args = mock_model.objects.filter.call_args
to assert isinstance(q_arg, Q) and add assertions q_arg.connector == "OR" and
that each child in q_arg.children is a tuple where the first element equals the
expected lookup names ("custom_field_data__librenms_id__default" and
"custom_field_data__librenms_id") and the second element equals the expected
librenms id value (e.g., 42); apply the same stricter assertions to the second
test block around lines 147-155 as well so regressions to a single combined
lookup are caught.

In `@netbox_librenms_plugin/tests/test_sync_interfaces.py`:
- Around line 109-115: Test uses a fresh MagicMock for iface.description so it
passes even if code sets None/empty; set iface.description to a unique sentinel
object (e.g., object()) before calling view.update_interface_attributes and then
assert that iface.description is still that sentinel (identity check) after the
call to prove it was untouched; reference the test function using
view.update_interface_attributes and the iface.description attribute to locate
where to set the sentinel and change the assertion.

In `@netbox_librenms_plugin/utils.py`:
- Around line 456-507: The current get_librenms_device_id treats legacy scalar
librenms_id values as valid for any server_key and leaves find_by_librenms_id
ignoring string IDs; change get_librenms_device_id to scope bare integer scalars
(and bare-string normalisation) to the default server only by returning the
scalar only when server_key == "default" (or matches the configured default
key), and when cf_value is dict keep per-server behaviour and normalise string
entries as already implemented (update obj.custom_field_data and save when
auto_save). Also update the corresponding lookup function find_by_librenms_id to
respect server scoping (only match scalar IDs for the default server) and to
consider string-stored IDs by normalising/handling strings the same way as
get_librenms_device_id so rows with "42" are discoverable without requiring a
write.

In `@netbox_librenms_plugin/views/imports/actions.py`:
- Around line 1121-1131: The other branches ('link', 'update', 'update_serial'
and the collision-check) still read/write a bare integer librenms_id; update
those paths to use the server-aware accessors: call
LibreNMSAPI.get_librenms_id(self.librenms_api, existing_device) when reading the
current mapping and use set_librenms_device_id(existing_device, librenms_id,
self.librenms_api.server_key) when writing so dict-backed per-server IDs aren't
overwritten; also update any conflict-check logic to compare via get_librenms_id
and adjust logging/messages to show the per-server dict format consistently and
keep the _save_device(existing_device) usage after writes.

In `@netbox_librenms_plugin/views/object_sync/devices.py`:
- Around line 129-145: The cached lookup uses server_key but the temporary table
here (VCInterfaceTable/LibreNMSInterfaceTable) is constructed without the same
server-scoped context, causing wrong rendering or failure; update this branch to
build the table the same way as DeviceInterfaceTableView.get_table() or pass
server_key into the table constructor so the table instantiation for
selected_device includes the server_key (e.g., reuse
DeviceInterfaceTableView.get_table(...) or call VCInterfaceTable(...,
server_key=server_key) / LibreNMSInterfaceTable(..., server_key=server_key))
before calling format_interface_data(port_data, selected_device).

In `@netbox_librenms_plugin/views/sync/device_fields.py`:
- Around line 423-429: The post method currently maps any
non-"virtualmachine"/non-"vm" object_type to Device, which can mis-route
permissions/lookup; modify post to validate request.POST["object_type"] against
an explicit allowlist (e.g., {"device", "vm", "virtualmachine"}) before setting
target_model and self.required_object_permissions, and reject unknown values
with a clear failure (raise BadRequest or return an HTTP 400) instead of
defaulting to Device; update the mapping logic that sets target_model (and the
normalization from "virtualmachine" -> "vm") so only allowed names select
VirtualMachine or Device and all other inputs are treated as invalid.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI (base), Organization UI (inherited)

Review profile: ASSERTIVE

Plan: Pro

Run ID: f35ce380-ada1-4809-b593-9803442e6921

📥 Commits

Reviewing files that changed from the base of the PR and between a9b956e and 24998bd.

📒 Files selected for processing (28)
  • netbox_librenms_plugin/forms.py
  • netbox_librenms_plugin/import_utils.py
  • netbox_librenms_plugin/librenms_api.py
  • netbox_librenms_plugin/static/netbox_librenms_plugin/js/librenms_import.js
  • netbox_librenms_plugin/static/netbox_librenms_plugin/js/librenms_sync.js
  • netbox_librenms_plugin/tables/device_status.py
  • netbox_librenms_plugin/tables/interfaces.py
  • netbox_librenms_plugin/templates/netbox_librenms_plugin/_interface_sync_content.html
  • netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_validation_details.html
  • netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html
  • netbox_librenms_plugin/tests/conftest.py
  • netbox_librenms_plugin/tests/test_import_utils.py
  • netbox_librenms_plugin/tests/test_librenms_id.py
  • netbox_librenms_plugin/tests/test_mixins.py
  • netbox_librenms_plugin/tests/test_sync_devices.py
  • netbox_librenms_plugin/tests/test_sync_interfaces.py
  • netbox_librenms_plugin/tests/test_sync_view_mismatch.py
  • netbox_librenms_plugin/urls.py
  • netbox_librenms_plugin/utils.py
  • netbox_librenms_plugin/views/__init__.py
  • netbox_librenms_plugin/views/base/interfaces_view.py
  • netbox_librenms_plugin/views/base/librenms_sync_view.py
  • netbox_librenms_plugin/views/imports/actions.py
  • netbox_librenms_plugin/views/mixins.py
  • netbox_librenms_plugin/views/object_sync/devices.py
  • netbox_librenms_plugin/views/object_sync/vms.py
  • netbox_librenms_plugin/views/sync/device_fields.py
  • netbox_librenms_plugin/views/sync/interfaces.py

Comment thread netbox_librenms_plugin/import_utils.py Outdated
Comment on lines +1368 to +1372
# Store librenms_id in per-server dict format so the mapping is
# traceable back to the source server (set_librenms_device_id also
# migrates any legacy bare-int value on write).
set_librenms_device_id(device, device_id, server_key or "default")
device.save()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Persist the resolved API server key here, not the raw function argument.

import_single_device() already resolves the active server through LibreNMSAPI(server_key=server_key), but this write still falls back to "default" when the caller omits server_key. That stores the mapping under the wrong server for callers that rely on the selected server in settings.

Suggested fix
-            set_librenms_device_id(device, device_id, server_key or "default")
+            set_librenms_device_id(device, device_id, api.server_key)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# Store librenms_id in per-server dict format so the mapping is
# traceable back to the source server (set_librenms_device_id also
# migrates any legacy bare-int value on write).
set_librenms_device_id(device, device_id, server_key or "default")
device.save()
# Store librenms_id in per-server dict format so the mapping is
# traceable back to the source server (set_librenms_device_id also
# migrates any legacy bare-int value on write).
set_librenms_device_id(device, device_id, api.server_key)
device.save()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@netbox_librenms_plugin/import_utils.py` around lines 1368 - 1372, The code in
import_single_device() writes the librenms_id mapping using the raw server_key
arg (or "default") which can be incorrect because
LibreNMSAPI(server_key=server_key) may resolve a different active server; update
the write to use the resolved server key from the API instance (e.g., use the
LibreNMSAPI object's server_key or equivalent resolved attribute) when calling
set_librenms_device_id(device, device_id, resolved_server_key) before
device.save(), so the mapping is persisted under the actual server used.

Comment thread netbox_librenms_plugin/tests/test_import_utils.py Outdated
Comment thread netbox_librenms_plugin/utils.py
Comment thread netbox_librenms_plugin/views/sync/device_fields.py Outdated
Comment thread netbox_librenms_plugin/views/sync/device_fields.py Outdated
Comment thread netbox_librenms_plugin/views/sync/device_fields.py Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
netbox_librenms_plugin/import_utils/device_operations.py (1)

160-205: ⚠️ Potential issue | 🟡 Minor

Initialize librenms_id_needs_migration in the base validation result.

Right now the key only exists on legacy matches. That makes the return shape inconsistent and forces every consumer to use .get() or handle KeyError. Seed it with False in result and only flip it to True in these branches.

🔧 Suggested fix
         "name_matches": False,  # True when existing device name matches LibreNMS sysName
         "name_sync_available": False,  # True when existing device name differs from sysName
         "suggested_name": None,  # sysName to suggest when name_sync_available is True
         "device_type_mismatch": False,  # True when existing device's type differs from LibreNMS
+        "librenms_id_needs_migration": False,
         "issues": [],
         "warnings": [],

Also applies to: 247-249, 270-272

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@netbox_librenms_plugin/import_utils/device_operations.py` around lines 160 -
205, The result dict created in the validation flow must include a default
"librenms_id_needs_migration": False key so the return shape is consistent; add
this key to the initial result literal (alongside keys like "is_ready",
"can_import", "resolved_name") and leave it False by default, then keep the
existing code paths that currently set librenms_id_needs_migration = True in the
legacy-match branches (the blocks that currently add that key only for legacy
matches) so they flip the value to True when appropriate (do not remove those
assignments, only ensure the key exists initially).
♻️ Duplicate comments (1)
netbox_librenms_plugin/views/sync/device_fields.py (1)

442-480: ⚠️ Potential issue | 🟠 Major

Handle legacy bare-integer librenms_id values in the removal path.

Both membership checks only accept dict-backed custom-field data. Objects still carrying the legacy integer format will always hit “No mapping found” here and can never be cleared, even though the rest of this PR still treats that value as a readable default mapping. Route these reads/writes through the shared librenms_id helpers, or normalize legacy values before both the pre-check and the locked re-check.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@netbox_librenms_plugin/views/sync/device_fields.py` around lines 442 - 480,
The removal path currently assumes obj.custom_field_data.get("librenms_id") is a
dict (cf_value) and rejects legacy bare-integer values; update the pre-check and
the locked re-check to normalize legacy integers into the dict form (or call the
shared librenms_id helper) before performing membership and deletion logic:
convert an int cf_value to {"default": int_value} (or use the existing helper
that returns a dict) when reading cf_value and when reloading
obj_locked.custom_field_data, then perform the server_key membership check and
deletion on that normalized dict and write back None when empty.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@netbox_librenms_plugin/import_utils/cache.py`:
- Line 65: Create a centralized cache-key formatter (e.g., a module-level
constant like LOCATION_CHOICES_KEY_FMT or a helper function
get_location_choices_cache_key(server_key)) and replace the hardcoded literal
"librenms_locations_choices:{server_key}" (see the use assigning
location_cache_key) with a call/format using that helper; update all
readers/writers in this module to use the new helper so the key format is
defined in one place and cannot drift.

In `@netbox_librenms_plugin/import_utils/device_operations.py`:
- Line 91: import_single_device currently re-validates objects using the
implicit "default" server when validation is None, causing server-aware lookups
to be bypassed and duplicates created; update the fallback validation path in
import_single_device to pass the active server_key (the server_key parameter)
into the validation/lookup call instead of hardcoding "default" so the lookup is
server-aware, and apply the same change to the other affected validation
fallbacks (the blocks around the regions referenced at 233-238 and 697-704) to
ensure all re-validations use the provided server_key.

---

Outside diff comments:
In `@netbox_librenms_plugin/import_utils/device_operations.py`:
- Around line 160-205: The result dict created in the validation flow must
include a default "librenms_id_needs_migration": False key so the return shape
is consistent; add this key to the initial result literal (alongside keys like
"is_ready", "can_import", "resolved_name") and leave it False by default, then
keep the existing code paths that currently set librenms_id_needs_migration =
True in the legacy-match branches (the blocks that currently add that key only
for legacy matches) so they flip the value to True when appropriate (do not
remove those assignments, only ensure the key exists initially).

---

Duplicate comments:
In `@netbox_librenms_plugin/views/sync/device_fields.py`:
- Around line 442-480: The removal path currently assumes
obj.custom_field_data.get("librenms_id") is a dict (cf_value) and rejects legacy
bare-integer values; update the pre-check and the locked re-check to normalize
legacy integers into the dict form (or call the shared librenms_id helper)
before performing membership and deletion logic: convert an int cf_value to
{"default": int_value} (or use the existing helper that returns a dict) when
reading cf_value and when reloading obj_locked.custom_field_data, then perform
the server_key membership check and deletion on that normalized dict and write
back None when empty.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI (base), Organization UI (inherited)

Review profile: ASSERTIVE

Plan: Pro

Run ID: a07be972-43fc-4d5b-a6d5-2e9467c5b2c1

📥 Commits

Reviewing files that changed from the base of the PR and between 24998bd and a89d0bd.

📒 Files selected for processing (4)
  • netbox_librenms_plugin/import_utils/cache.py
  • netbox_librenms_plugin/import_utils/device_operations.py
  • netbox_librenms_plugin/tests/test_import_utils.py
  • netbox_librenms_plugin/views/sync/device_fields.py
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: test-netbox (3.12)
  • GitHub Check: test-netbox (3.14)
🧰 Additional context used
📓 Path-based instructions (4)
netbox_librenms_plugin/**/*.py

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

netbox_librenms_plugin/**/*.py: Hook into NetBox Django 5 plugin APIs via navigation.py, urls.py, and api/ modules under netbox_librenms_plugin/; respect NetBox plugin conventions
Use LibreNMSAPI.get_librenms_id() instead of directly accessing the librenms_id custom field when mapping Devices/VMs to LibreNMS
Reuse librenms_api.py client for all LibreNMS communication instead of making direct requests calls; it handles multi-server configs via LibreNMSSettings model and caching
Always use exact-only matching (not fuzzy) for site, platform, device type, and role via functions in utils.py (find_matching_site, match_librenms_hardware_to_device_type, find_matching_platform)
Centralize validation state mutation during import using import_validation_helpers.py for role/cluster/rack assignment, issue removal, and status recalculation
Use get_virtual_chassis_member() for port-to-member mapping and get_librenms_sync_device() for VC priority-based device selection in virtual chassis operations

Files:

  • netbox_librenms_plugin/import_utils/cache.py
  • netbox_librenms_plugin/tests/test_import_utils.py
  • netbox_librenms_plugin/views/sync/device_fields.py
  • netbox_librenms_plugin/import_utils/device_operations.py
netbox_librenms_plugin/views/**/*.py

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

netbox_librenms_plugin/views/**/*.py: Views must follow a three-layer structure: Base views in views/base/, Object sync views in views/object_sync/, and Sync action views in views/sync/ with shared mixins from views/mixins.py
New views must extend the closest base class and compose mixins from views/mixins.py (LibreNMSPermissionMixin, NetBoxObjectPermissionMixin, LibreNMSAPIMixin, CacheMixin, VlanAssignmentMixin)
All views must inherit LibreNMSPermissionMixin from views/mixins.py with permission_required = PERM_VIEW_PLUGIN
Declare required_object_permissions as a dict mapping HTTP methods to [(action, Model)] tuples for NetBox model operations; some views may set this dynamically per-request
Use _get_safe_redirect_url(request) to validate referrer URLs in permission checks to prevent open-redirect attacks

Files:

  • netbox_librenms_plugin/views/sync/device_fields.py
netbox_librenms_plugin/views/sync/**/*.py

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

netbox_librenms_plugin/views/sync/**/*.py: Sync POST handlers must call require_all_permissions() (not just require_write_permission()) and return early if it returns a response; use require_all_permissions_json() for AJAX/JSON endpoints
Follow sync conventions defined in .github/instructions/sync.instructions.md for sync views, base views, tables, and sync JavaScript

Files:

  • netbox_librenms_plugin/views/sync/device_fields.py
**/views/sync/**/*.py

📄 CodeRabbit inference engine (.github/instructions/sync.instructions.md)

Sync action views must follow the pattern: check permissions with LibreNMSPermissionMixin and NetBoxObjectPermissionMixin, read selected items from request.POST.getlist('select'), load cached data using CacheMixin.get_cache_key(), apply changes inside transaction.atomic(), and redirect to the sync tab with ?tab=<resource>.

Files:

  • netbox_librenms_plugin/views/sync/device_fields.py
🧠 Learnings (36)
📓 Common learnings
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-03-03T13:23:33.731Z
Learning: Applies to netbox_librenms_plugin/**/*.py : Use `LibreNMSAPI.get_librenms_id()` instead of directly accessing the `librenms_id` custom field when mapping Devices/VMs to LibreNMS
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-03-03T13:23:33.731Z
Learning: Applies to netbox_librenms_plugin/**/*.py : Reuse `librenms_api.py` client for all LibreNMS communication instead of making direct `requests` calls; it handles multi-server configs via `LibreNMSSettings` model and caching
📚 Learning: 2026-03-03T13:23:33.731Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-03-03T13:23:33.731Z
Learning: Applies to netbox_librenms_plugin/**/*.py : Reuse `librenms_api.py` client for all LibreNMS communication instead of making direct `requests` calls; it handles multi-server configs via `LibreNMSSettings` model and caching

Applied to files:

  • netbox_librenms_plugin/import_utils/cache.py
  • netbox_librenms_plugin/views/sync/device_fields.py
  • netbox_librenms_plugin/import_utils/device_operations.py
📚 Learning: 2026-03-03T13:24:51.364Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/testing.instructions.md:0-0
Timestamp: 2026-03-03T13:24:51.364Z
Learning: Applies to tests/**/*.py : Patch `cache` where imported: `netbox_librenms_plugin.views.imports.list.cache`, not `django.core.cache.cache`.

Applied to files:

  • netbox_librenms_plugin/import_utils/cache.py
  • netbox_librenms_plugin/tests/test_import_utils.py
📚 Learning: 2026-03-03T13:24:38.082Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/sync.instructions.md:0-0
Timestamp: 2026-03-03T13:24:38.082Z
Learning: Applies to **/views/base/**/*.py|**/views/object_sync/**/*.py|**/views/sync/**/*.py : Cache keys for sync data must follow the format: `librenms_{data_type}_{model_name}_{pk}` for fetched data and `librenms_{data_type}_last_fetched_{model_name}_{pk}` for fetch timestamps. VLAN group overrides must use `get_vlan_overrides_key(obj)`.

Applied to files:

  • netbox_librenms_plugin/import_utils/cache.py
📚 Learning: 2026-03-03T13:24:00.657Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/background-jobs.instructions.md:0-0
Timestamp: 2026-03-03T13:24:00.657Z
Learning: Applies to **/import_utils.py,**/jobs.py : Cache key generation must use helper functions: `get_validated_device_cache_key()`, `get_cache_metadata_key()`, `get_active_cached_searches()`, `get_import_device_cache_key()`. Never hardcode cache key formats

Applied to files:

  • netbox_librenms_plugin/import_utils/cache.py
📚 Learning: 2026-03-03T13:24:51.364Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/testing.instructions.md:0-0
Timestamp: 2026-03-03T13:24:51.364Z
Learning: Applies to tests/**/*.py : Patch deferred/inline imports at their source module (e.g., `netbox_librenms_plugin.import_utils.process_device_filters`), not the consuming module.

Applied to files:

  • netbox_librenms_plugin/tests/test_import_utils.py
  • netbox_librenms_plugin/import_utils/device_operations.py
📚 Learning: 2026-03-03T13:24:51.364Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/testing.instructions.md:0-0
Timestamp: 2026-03-03T13:24:51.364Z
Learning: Applies to tests/**/*.py : Reuse fixtures from `tests/conftest.py` instead of creating ad-hoc mocks: Configuration (`mock_multi_server_config`, `mock_legacy_config`), API client (`mock_librenms_api`), NetBox objects (`mock_netbox_device`, `mock_netbox_vm`, `mock_netbox_site`, `mock_netbox_platform`, `mock_netbox_device_type`, `mock_netbox_device_role`, `mock_netbox_cluster`, `mock_netbox_rack`), HTTP responses (`mock_response_factory`, `mock_success_response`, `mock_device_response`, `mock_error_response`, `mock_auth_error_response`), and Import workflow (`sample_librenms_device`, `sample_librenms_device_minimal`, `sample_validation_state`, `sample_validation_state_vm`).

Applied to files:

  • netbox_librenms_plugin/tests/test_import_utils.py
📚 Learning: 2026-03-03T13:24:51.364Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/testing.instructions.md:0-0
Timestamp: 2026-03-03T13:24:51.364Z
Learning: Applies to tests/**/*.py : Mock NetBox models (Device, Job, User) with `MagicMock()` instead of creating real instances.

Applied to files:

  • netbox_librenms_plugin/tests/test_import_utils.py
📚 Learning: 2026-03-03T13:24:51.364Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/testing.instructions.md:0-0
Timestamp: 2026-03-03T13:24:51.364Z
Learning: Applies to tests/test_netbox_librenms_plugin.py : `test_netbox_librenms_plugin.py` is an empty placeholder — do not add tests there.

Applied to files:

  • netbox_librenms_plugin/tests/test_import_utils.py
📚 Learning: 2026-03-03T13:23:33.731Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-03-03T13:23:33.731Z
Learning: Applies to netbox_librenms_plugin/**/*.py : Always use exact-only matching (not fuzzy) for site, platform, device type, and role via functions in `utils.py` (`find_matching_site`, `match_librenms_hardware_to_device_type`, `find_matching_platform`)

Applied to files:

  • netbox_librenms_plugin/tests/test_import_utils.py
  • netbox_librenms_plugin/views/sync/device_fields.py
  • netbox_librenms_plugin/import_utils/device_operations.py
📚 Learning: 2026-03-03T13:24:00.657Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/background-jobs.instructions.md:0-0
Timestamp: 2026-03-03T13:24:00.657Z
Learning: Applies to **/import_utils.py : `process_device_filters(filters, ...)` must fetch and validate devices from LibreNMS, returning a list

Applied to files:

  • netbox_librenms_plugin/tests/test_import_utils.py
  • netbox_librenms_plugin/import_utils/device_operations.py
📚 Learning: 2026-03-03T13:24:51.364Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/testing.instructions.md:0-0
Timestamp: 2026-03-03T13:24:51.364Z
Learning: Applies to tests/**/*.py : Cache key tests must patch `get_validated_device_cache_key` from `import_utils.py`; never hardcode key formats like `job_123_device_1`.

Applied to files:

  • netbox_librenms_plugin/tests/test_import_utils.py
📚 Learning: 2026-03-03T13:23:33.731Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-03-03T13:23:33.731Z
Learning: Applies to netbox_librenms_plugin/**/*.py : Use `LibreNMSAPI.get_librenms_id()` instead of directly accessing the `librenms_id` custom field when mapping Devices/VMs to LibreNMS

Applied to files:

  • netbox_librenms_plugin/tests/test_import_utils.py
  • netbox_librenms_plugin/views/sync/device_fields.py
  • netbox_librenms_plugin/import_utils/device_operations.py
📚 Learning: 2026-03-03T13:23:33.731Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-03-03T13:23:33.731Z
Learning: Applies to netbox_librenms_plugin/**/*.py : Centralize validation state mutation during import using `import_validation_helpers.py` for role/cluster/rack assignment, issue removal, and status recalculation

Applied to files:

  • netbox_librenms_plugin/tests/test_import_utils.py
  • netbox_librenms_plugin/views/sync/device_fields.py
  • netbox_librenms_plugin/import_utils/device_operations.py
📚 Learning: 2026-03-03T13:23:33.731Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-03-03T13:23:33.731Z
Learning: Applies to netbox_librenms_plugin/**/*.py : Use `get_virtual_chassis_member()` for port-to-member mapping and `get_librenms_sync_device()` for VC priority-based device selection in virtual chassis operations

Applied to files:

  • netbox_librenms_plugin/tests/test_import_utils.py
  • netbox_librenms_plugin/views/sync/device_fields.py
  • netbox_librenms_plugin/import_utils/device_operations.py
📚 Learning: 2026-03-03T13:24:38.082Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/sync.instructions.md:0-0
Timestamp: 2026-03-03T13:24:38.082Z
Learning: Applies to **/views/sync/**/*.py : Sync action views must follow the pattern: check permissions with `LibreNMSPermissionMixin` and `NetBoxObjectPermissionMixin`, read selected items from `request.POST.getlist('select')`, load cached data using `CacheMixin.get_cache_key()`, apply changes inside `transaction.atomic()`, and redirect to the sync tab with `?tab=<resource>`.

Applied to files:

  • netbox_librenms_plugin/views/sync/device_fields.py
📚 Learning: 2026-03-03T13:23:33.731Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-03-03T13:23:33.731Z
Learning: Applies to netbox_librenms_plugin/views/sync/**/*.py : Follow sync conventions defined in `.github/instructions/sync.instructions.md` for sync views, base views, tables, and sync JavaScript

Applied to files:

  • netbox_librenms_plugin/views/sync/device_fields.py
📚 Learning: 2026-03-03T13:23:33.731Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-03-03T13:23:33.731Z
Learning: Applies to netbox_librenms_plugin/api/**/*.py : Update API serializers in `api/serializers.py` and `api/views.py` together to prevent contract drift between models and external API consumers

Applied to files:

  • netbox_librenms_plugin/views/sync/device_fields.py
📚 Learning: 2026-03-03T13:23:33.731Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-03-03T13:23:33.731Z
Learning: Applies to netbox_librenms_plugin/models.py : Coordinate schema changes through Django migrations in `migrations/` directory; update `models.py`, admin, and Pydantic representations accordingly

Applied to files:

  • netbox_librenms_plugin/views/sync/device_fields.py
📚 Learning: 2026-03-03T13:23:33.731Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-03-03T13:23:33.731Z
Learning: Applies to netbox_librenms_plugin/views/**/*.py : Views must follow a three-layer structure: Base views in `views/base/`, Object sync views in `views/object_sync/`, and Sync action views in `views/sync/` with shared mixins from `views/mixins.py`

Applied to files:

  • netbox_librenms_plugin/views/sync/device_fields.py
📚 Learning: 2026-03-03T13:24:18.134Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/frontend.instructions.md:0-0
Timestamp: 2026-03-03T13:24:18.134Z
Learning: Applies to netbox_librenms_plugin/{templates,tables}/**/*.{html,py} : Templates live in `templates/netbox_librenms_plugin/`; reuse/includes under `inc/`. Sync pages extend `librenms_sync_base.html`. Tables emit HTMX-enabled columns and buttons (`tables/*.py`), so prefer updating the table renderer in Python rather than templates when changing row actions.

Applied to files:

  • netbox_librenms_plugin/views/sync/device_fields.py
📚 Learning: 2026-03-03T13:24:00.657Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/background-jobs.instructions.md:0-0
Timestamp: 2026-03-03T13:24:00.657Z
Learning: Applies to **/views/imports/** : `BulkImportDevicesView` (POST) must execute import: background mode enqueues `ImportDevicesJob`; sync mode calls `bulk_import_devices()` + `bulk_import_vms()` and returns OOB row swaps with `HX-Trigger: closeModal`

Applied to files:

  • netbox_librenms_plugin/views/sync/device_fields.py
📚 Learning: 2026-03-03T13:24:38.082Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/sync.instructions.md:0-0
Timestamp: 2026-03-03T13:24:38.082Z
Learning: Applies to **/librenms_sync.js : JavaScript VLAN modal functions must implement `openVlanDetailModal()`, `verifyVlanInGroup()`, `verifyVlanSyncGroup()` for per-interface VLAN detail editing.

Applied to files:

  • netbox_librenms_plugin/views/sync/device_fields.py
📚 Learning: 2026-03-03T13:24:38.082Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/sync.instructions.md:0-0
Timestamp: 2026-03-03T13:24:38.082Z
Learning: Applies to **/librenms_sync.js : JavaScript verification functions must include `handleInterfaceChange()`, `handleCableChange()`, `handleVRFChange()` that POST to single-item verify endpoints to validate resource changes.

Applied to files:

  • netbox_librenms_plugin/views/sync/device_fields.py
📚 Learning: 2026-03-03T13:23:33.731Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-03-03T13:23:33.731Z
Learning: Applies to netbox_librenms_plugin/views/**/*.py : Declare `required_object_permissions` as a dict mapping HTTP methods to `[(action, Model)]` tuples for NetBox model operations; some views may set this dynamically per-request

Applied to files:

  • netbox_librenms_plugin/views/sync/device_fields.py
📚 Learning: 2026-03-03T13:23:33.731Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-03-03T13:23:33.731Z
Learning: Applies to netbox_librenms_plugin/views/sync/**/*.py : Sync POST handlers must call `require_all_permissions()` (not just `require_write_permission()`) and return early if it returns a response; use `require_all_permissions_json()` for AJAX/JSON endpoints

Applied to files:

  • netbox_librenms_plugin/views/sync/device_fields.py
📚 Learning: 2026-03-03T13:24:51.364Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/testing.instructions.md:0-0
Timestamp: 2026-03-03T13:24:51.364Z
Learning: Applies to tests/test_permissions.py : For `NetBoxObjectPermissionMixin` tests, set `required_object_permissions` on the instance before calling `check_object_permissions()`.

Applied to files:

  • netbox_librenms_plugin/views/sync/device_fields.py
📚 Learning: 2026-03-03T13:23:33.731Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-03-03T13:23:33.731Z
Learning: Applies to netbox_librenms_plugin/api/views.py : API endpoints must use `LibreNMSPluginPermission` class in `api/views.py` where GET checks `view_librenmssettings` and other methods check `change_librenmssettings`

Applied to files:

  • netbox_librenms_plugin/views/sync/device_fields.py
📚 Learning: 2026-03-03T13:23:33.731Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-03-03T13:23:33.731Z
Learning: Applies to netbox_librenms_plugin/views/**/*.py : All views must inherit `LibreNMSPermissionMixin` from `views/mixins.py` with `permission_required = PERM_VIEW_PLUGIN`

Applied to files:

  • netbox_librenms_plugin/views/sync/device_fields.py
📚 Learning: 2026-03-03T13:23:33.731Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-03-03T13:23:33.731Z
Learning: Applies to netbox_librenms_plugin/views/**/*.py : New views must extend the closest base class and compose mixins from `views/mixins.py` (`LibreNMSPermissionMixin`, `NetBoxObjectPermissionMixin`, `LibreNMSAPIMixin`, `CacheMixin`, `VlanAssignmentMixin`)

Applied to files:

  • netbox_librenms_plugin/views/sync/device_fields.py
📚 Learning: 2026-03-03T13:24:38.082Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/sync.instructions.md:0-0
Timestamp: 2026-03-03T13:24:38.082Z
Learning: Applies to **/views/object_sync/**/*.py : Object sync view methods must create instances of concrete table views, copy the `request` object, and call `get_context_data()`. VMs must skip cables and VLANs by returning `None` from those `get_*_context()` methods.

Applied to files:

  • netbox_librenms_plugin/views/sync/device_fields.py
📚 Learning: 2026-03-03T13:24:00.657Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/background-jobs.instructions.md:0-0
Timestamp: 2026-03-03T13:24:00.657Z
Learning: Applies to **/views/imports/** : Per-device dropdown update views (`DeviceRoleUpdateView`, `DeviceClusterUpdateView`, `DeviceRackUpdateView`) must apply selection to validation state and return re-rendered row via `render_device_row()`

Applied to files:

  • netbox_librenms_plugin/views/sync/device_fields.py
📚 Learning: 2026-03-03T13:24:00.657Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/background-jobs.instructions.md:0-0
Timestamp: 2026-03-03T13:24:00.657Z
Learning: Applies to **/jobs.py : `ImportDevicesJob` background job: imports devices and VMs. Must call `bulk_import_devices_shared()` for devices and `bulk_import_vms()` for VMs. `job.data` keys must include `imported_device_pks`, `imported_vm_pks`, `imported_libre_device_ids`, `imported_libre_vm_ids`, `server_key`, `total`, `success_count`, `failed_count`, `skipped_count`, `virtual_chassis_created`, `errors`, `completed`

Applied to files:

  • netbox_librenms_plugin/import_utils/device_operations.py
📚 Learning: 2026-03-03T13:24:00.657Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/background-jobs.instructions.md:0-0
Timestamp: 2026-03-03T13:24:00.657Z
Learning: Applies to **/import_utils.py : `validate_device_for_import(device, ...)` is the core validation function that produces validation state dict

Applied to files:

  • netbox_librenms_plugin/import_utils/device_operations.py
📚 Learning: 2026-03-03T13:24:00.657Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/background-jobs.instructions.md:0-0
Timestamp: 2026-03-03T13:24:00.657Z
Learning: Applies to **/import_utils.py : `bulk_import_devices_shared(devices, user, ...)` must be the shared implementation between sync and background import

Applied to files:

  • netbox_librenms_plugin/import_utils/device_operations.py
📚 Learning: 2026-03-03T13:24:38.082Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/sync.instructions.md:0-0
Timestamp: 2026-03-03T13:24:38.082Z
Learning: Applies to **/views/base/**/*.py|**/views/object_sync/**/*.py : Use `LibreNMSAPIMixin` to provide lazy-loaded `LibreNMSAPI` instance via `self.librenms_api` property and `get_server_info()` method for template context.

Applied to files:

  • netbox_librenms_plugin/import_utils/device_operations.py
🔇 Additional comments (8)
netbox_librenms_plugin/tests/test_import_utils.py (6)

1345-1356: The blanket return_value approach still doesn't scope the mock to librenms-id queries only.

The docstring explains the Q-object usage but doesn't address the core concern: with return_value, every Device.objects.filter(...).first() returns existing_device, so tests pass even if the code falls through to hostname/serial matching rather than using find_by_librenms_id().


1645-1651: LGTM!

The *args addition correctly allows device_filter to accept positional Q-object arguments from find_by_librenms_id(), while the existing kwargs-based routing (if "serial" in kwargs) handles differentiation between query types.


1836-1843: LGTM!

This is the correct approach for scoping mock responses: checking if args: distinguishes Q-object calls from find_by_librenms_id() versus keyword-based queries (serial, hostname), ensuring accurate test behavior.


1876-1886: LGTM!

The combined logic correctly handles librenms_id lookups (via args check), serial drift detection, and excludes/filters appropriately. This provides accurate mock behavior for the test scenarios.


1918-1925: LGTM!

Consistent with the established pattern for Q-object detection. The mock properly returns existing only for librenms_id lookups while returning None for other query types.


1961-1968: LGTM!

The signature update to *args, **kwargs ensures compatibility with Q-object calls while maintaining the serial-based routing logic.

netbox_librenms_plugin/views/sync/device_fields.py (2)

285-294: Atomic platform creation/assignment looks solid.

Wrapping both writes in one transaction avoids leaving an orphaned Platform behind if the device update fails.


423-429: Scoped per-model permission checks are fixed.

Resolving object_type before require_all_permissions() keeps the POST permission gate aligned with the object actually being mutated.

Comment thread netbox_librenms_plugin/import_utils/cache.py Outdated
Comment thread netbox_librenms_plugin/import_utils/device_operations.py Outdated
Comment thread netbox_librenms_plugin/views/sync/device_fields.py Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (2)
netbox_librenms_plugin/views/sync/device_fields.py (2)

442-480: ⚠️ Potential issue | 🟠 Major

Normalize legacy bare-integer mappings before both membership checks.

The rest of the PR still treats a legacy bare integer as the readable "default" mapping, but Line 442 and Line 473 only accept dict-backed values. An object still storing librenms_id = 42 will always hit “No mapping found” or “already removed” here and cannot be cleaned up through this endpoint. Normalize the legacy value before both checks, or route deletion through the shared helper layer.

🛠️ Minimal fix
-        cf_value = obj.custom_field_data.get("librenms_id")
+        cf_value = obj.custom_field_data.get("librenms_id")
+        if isinstance(cf_value, int):
+            cf_value = {"default": cf_value}
         if not isinstance(cf_value, dict) or server_key not in cf_value:
             messages.warning(request, f"No mapping found for server '{server_key}'.")
             return redirect(sync_url, pk=pk)
@@
-            cf = obj_locked.custom_field_data.get("librenms_id", {})
+            cf = obj_locked.custom_field_data.get("librenms_id", {})
+            if isinstance(cf, int):
+                cf = {"default": cf}
             # Re-check after acquiring lock; mirror the pre-transaction protection logic
             _is_protected = server_key in configured_servers or (
                 legacy_url_configured and not configured_servers and server_key == "default"
             )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@netbox_librenms_plugin/views/sync/device_fields.py` around lines 442 - 480,
The handlers use cf_value = obj.custom_field_data.get("librenms_id") and later
cf = obj_locked.custom_field_data.get("librenms_id", {}) but only treat dicts,
so legacy bare integers (e.g. 42) are ignored; normalize a legacy integer/string
librenms_id into the dict form { "default": <value> } before both the first
membership check (where cf_value is inspected for server_key) and before the
locked re-check (where cf is read from obj_locked) so the subsequent logic that
checks server_key and deletes the mapping works for legacy single-server
entries; update the code around cf_value and cf normalization (referencing
cf_value, server_key, configured_servers, legacy_url_configured, obj_locked, cf)
to perform this conversion.

423-429: ⚠️ Potential issue | 🟠 Major

Fail closed on unexpected object_type values.

This only remaps "virtualmachine" to "vm"; every other unexpected value still falls through to Device. A malformed or tampered POST can therefore scope permissions, lookups, and deletion to the wrong model when device and VM PKs overlap. Normalize with .strip().lower() and reject anything outside the allowed set before selecting target_model.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@netbox_librenms_plugin/views/sync/device_fields.py` around lines 423 - 429,
Normalize and validate the incoming object_type in the post method: call
.strip().lower() on request.POST.get("object_type", "device"), only allow the
explicit set {"device","virtualmachine","vm"}, and if the value is not in that
set immediately reject the request (e.g., raise PermissionDenied or return
HttpResponseBadRequest) instead of falling back to Device; then map
"virtualmachine" to "vm" and set target_model = VirtualMachine if object_type ==
"vm" else Device and keep setting self.required_object_permissions = {"POST":
[("change", target_model)]}.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@netbox_librenms_plugin/views/sync/device_fields.py`:
- Around line 311-319: The catch-all ValidationError handler for the block
around device.full_clean() incorrectly always reports a "slug collision"
platform error; update the error handling so you distinguish platform creation
failures from device validation failures: either split the try/except so
platform creation (the code that uses platform_name/slug) has its own except
that shows the slug-collision message, and the device.full_clean() call has a
separate except that surfaces the actual ValidationError e (use
logger.error(..., exc_info=True) and messages.error(request, str(e)) or a
user-friendly variant), or keep a single handler but inspect the ValidationError
contents before choosing the platform-slug message; target the ValidationError
handling around device.full_clean(), logger.error, and messages.error to
implement this.

---

Duplicate comments:
In `@netbox_librenms_plugin/views/sync/device_fields.py`:
- Around line 442-480: The handlers use cf_value =
obj.custom_field_data.get("librenms_id") and later cf =
obj_locked.custom_field_data.get("librenms_id", {}) but only treat dicts, so
legacy bare integers (e.g. 42) are ignored; normalize a legacy integer/string
librenms_id into the dict form { "default": <value> } before both the first
membership check (where cf_value is inspected for server_key) and before the
locked re-check (where cf is read from obj_locked) so the subsequent logic that
checks server_key and deletes the mapping works for legacy single-server
entries; update the code around cf_value and cf normalization (referencing
cf_value, server_key, configured_servers, legacy_url_configured, obj_locked, cf)
to perform this conversion.
- Around line 423-429: Normalize and validate the incoming object_type in the
post method: call .strip().lower() on request.POST.get("object_type", "device"),
only allow the explicit set {"device","virtualmachine","vm"}, and if the value
is not in that set immediately reject the request (e.g., raise PermissionDenied
or return HttpResponseBadRequest) instead of falling back to Device; then map
"virtualmachine" to "vm" and set target_model = VirtualMachine if object_type ==
"vm" else Device and keep setting self.required_object_permissions = {"POST":
[("change", target_model)]}.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI (base), Organization UI (inherited)

Review profile: ASSERTIVE

Plan: Pro

Run ID: 4edf8324-bf11-4fe2-98d6-9353a3a7dbc8

📥 Commits

Reviewing files that changed from the base of the PR and between a89d0bd and ad11705.

📒 Files selected for processing (2)
  • netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html
  • netbox_librenms_plugin/views/sync/device_fields.py
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: test-netbox (3.14)
🧰 Additional context used
📓 Path-based instructions (7)
netbox_librenms_plugin/**/*.py

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

netbox_librenms_plugin/**/*.py: Hook into NetBox Django 5 plugin APIs via navigation.py, urls.py, and api/ modules under netbox_librenms_plugin/; respect NetBox plugin conventions
Use LibreNMSAPI.get_librenms_id() instead of directly accessing the librenms_id custom field when mapping Devices/VMs to LibreNMS
Reuse librenms_api.py client for all LibreNMS communication instead of making direct requests calls; it handles multi-server configs via LibreNMSSettings model and caching
Always use exact-only matching (not fuzzy) for site, platform, device type, and role via functions in utils.py (find_matching_site, match_librenms_hardware_to_device_type, find_matching_platform)
Centralize validation state mutation during import using import_validation_helpers.py for role/cluster/rack assignment, issue removal, and status recalculation
Use get_virtual_chassis_member() for port-to-member mapping and get_librenms_sync_device() for VC priority-based device selection in virtual chassis operations

Files:

  • netbox_librenms_plugin/views/sync/device_fields.py
netbox_librenms_plugin/views/**/*.py

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

netbox_librenms_plugin/views/**/*.py: Views must follow a three-layer structure: Base views in views/base/, Object sync views in views/object_sync/, and Sync action views in views/sync/ with shared mixins from views/mixins.py
New views must extend the closest base class and compose mixins from views/mixins.py (LibreNMSPermissionMixin, NetBoxObjectPermissionMixin, LibreNMSAPIMixin, CacheMixin, VlanAssignmentMixin)
All views must inherit LibreNMSPermissionMixin from views/mixins.py with permission_required = PERM_VIEW_PLUGIN
Declare required_object_permissions as a dict mapping HTTP methods to [(action, Model)] tuples for NetBox model operations; some views may set this dynamically per-request
Use _get_safe_redirect_url(request) to validate referrer URLs in permission checks to prevent open-redirect attacks

Files:

  • netbox_librenms_plugin/views/sync/device_fields.py
netbox_librenms_plugin/views/sync/**/*.py

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

netbox_librenms_plugin/views/sync/**/*.py: Sync POST handlers must call require_all_permissions() (not just require_write_permission()) and return early if it returns a response; use require_all_permissions_json() for AJAX/JSON endpoints
Follow sync conventions defined in .github/instructions/sync.instructions.md for sync views, base views, tables, and sync JavaScript

Files:

  • netbox_librenms_plugin/views/sync/device_fields.py
**/views/sync/**/*.py

📄 CodeRabbit inference engine (.github/instructions/sync.instructions.md)

Sync action views must follow the pattern: check permissions with LibreNMSPermissionMixin and NetBoxObjectPermissionMixin, read selected items from request.POST.getlist('select'), load cached data using CacheMixin.get_cache_key(), apply changes inside transaction.atomic(), and redirect to the sync tab with ?tab=<resource>.

Files:

  • netbox_librenms_plugin/views/sync/device_fields.py
netbox_librenms_plugin/templates/**/*.html

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

Follow frontend conventions for templates and static files defined in .github/instructions/frontend.instructions.md

netbox_librenms_plugin/templates/**/*.html: HTMX 2.x is the primary async layer for table row updates. Table row updates must return <tr hx-swap-oob="true">. Avoid outerHTML swaps; use OOB (Out-of-Band) or targeted innerHTML swaps to keep table layout intact.
Styling assumes Tabler defaults. Removing table-responsive wrappers was deliberate to prevent dropdown clipping—do not re-add them.

Files:

  • netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html
netbox_librenms_plugin/{templates,static}/**/*.{html,js}

📄 CodeRabbit inference engine (.github/instructions/frontend.instructions.md)

netbox_librenms_plugin/{templates,static}/**/*.{html,js}: All HTMX requests and fetch() calls must include a CSRF token. The standard pattern is document.querySelector('[name=csrfmiddlewaretoken]').value (from a hidden form input). The import JS also uses getCookie('csrftoken') as a fallback — prefer the hidden input approach for consistency.
Modals use Tabler (Bootstrap-like) but without bootstrap.Modal helpers. Buttons target the htmx-modal-content element and JavaScript in librenms_import.html toggles the wrapper. Do not reintroduce data-bs-toggle or duplicate modal IDs.

Files:

  • netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html
netbox_librenms_plugin/{templates,tables}/**/*.{html,py}

📄 CodeRabbit inference engine (.github/instructions/frontend.instructions.md)

Templates live in templates/netbox_librenms_plugin/; reuse/includes under inc/. Sync pages extend librenms_sync_base.html. Tables emit HTMX-enabled columns and buttons (tables/*.py), so prefer updating the table renderer in Python rather than templates when changing row actions.

Files:

  • netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html
🧠 Learnings (32)
📓 Common learnings
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-03-03T13:23:33.731Z
Learning: Applies to netbox_librenms_plugin/**/*.py : Use `LibreNMSAPI.get_librenms_id()` instead of directly accessing the `librenms_id` custom field when mapping Devices/VMs to LibreNMS
📚 Learning: 2026-03-03T13:23:33.731Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-03-03T13:23:33.731Z
Learning: Applies to netbox_librenms_plugin/views/sync/**/*.py : Follow sync conventions defined in `.github/instructions/sync.instructions.md` for sync views, base views, tables, and sync JavaScript

Applied to files:

  • netbox_librenms_plugin/views/sync/device_fields.py
  • netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html
📚 Learning: 2026-03-03T13:24:38.082Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/sync.instructions.md:0-0
Timestamp: 2026-03-03T13:24:38.082Z
Learning: Applies to **/views/sync/**/*.py : Sync action views must follow the pattern: check permissions with `LibreNMSPermissionMixin` and `NetBoxObjectPermissionMixin`, read selected items from `request.POST.getlist('select')`, load cached data using `CacheMixin.get_cache_key()`, apply changes inside `transaction.atomic()`, and redirect to the sync tab with `?tab=<resource>`.

Applied to files:

  • netbox_librenms_plugin/views/sync/device_fields.py
  • netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html
📚 Learning: 2026-03-03T13:23:33.731Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-03-03T13:23:33.731Z
Learning: Applies to netbox_librenms_plugin/api/**/*.py : Update API serializers in `api/serializers.py` and `api/views.py` together to prevent contract drift between models and external API consumers

Applied to files:

  • netbox_librenms_plugin/views/sync/device_fields.py
  • netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html
📚 Learning: 2026-03-03T13:23:33.731Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-03-03T13:23:33.731Z
Learning: Applies to netbox_librenms_plugin/**/*.py : Centralize validation state mutation during import using `import_validation_helpers.py` for role/cluster/rack assignment, issue removal, and status recalculation

Applied to files:

  • netbox_librenms_plugin/views/sync/device_fields.py
📚 Learning: 2026-03-03T13:23:33.731Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-03-03T13:23:33.731Z
Learning: Applies to netbox_librenms_plugin/**/*.py : Use `get_virtual_chassis_member()` for port-to-member mapping and `get_librenms_sync_device()` for VC priority-based device selection in virtual chassis operations

Applied to files:

  • netbox_librenms_plugin/views/sync/device_fields.py
  • netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html
📚 Learning: 2026-03-03T13:23:33.731Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-03-03T13:23:33.731Z
Learning: Applies to netbox_librenms_plugin/**/*.py : Use `LibreNMSAPI.get_librenms_id()` instead of directly accessing the `librenms_id` custom field when mapping Devices/VMs to LibreNMS

Applied to files:

  • netbox_librenms_plugin/views/sync/device_fields.py
📚 Learning: 2026-03-03T13:23:33.731Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-03-03T13:23:33.731Z
Learning: Applies to netbox_librenms_plugin/models.py : Coordinate schema changes through Django migrations in `migrations/` directory; update `models.py`, admin, and Pydantic representations accordingly

Applied to files:

  • netbox_librenms_plugin/views/sync/device_fields.py
📚 Learning: 2026-03-03T13:24:18.134Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/frontend.instructions.md:0-0
Timestamp: 2026-03-03T13:24:18.134Z
Learning: Applies to netbox_librenms_plugin/{templates,tables}/**/*.{html,py} : Templates live in `templates/netbox_librenms_plugin/`; reuse/includes under `inc/`. Sync pages extend `librenms_sync_base.html`. Tables emit HTMX-enabled columns and buttons (`tables/*.py`), so prefer updating the table renderer in Python rather than templates when changing row actions.

Applied to files:

  • netbox_librenms_plugin/views/sync/device_fields.py
  • netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html
📚 Learning: 2026-03-03T13:23:33.731Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-03-03T13:23:33.731Z
Learning: Applies to netbox_librenms_plugin/views/**/*.py : Views must follow a three-layer structure: Base views in `views/base/`, Object sync views in `views/object_sync/`, and Sync action views in `views/sync/` with shared mixins from `views/mixins.py`

Applied to files:

  • netbox_librenms_plugin/views/sync/device_fields.py
📚 Learning: 2026-03-03T13:23:33.731Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-03-03T13:23:33.731Z
Learning: Applies to netbox_librenms_plugin/views/sync/**/*.py : Sync POST handlers must call `require_all_permissions()` (not just `require_write_permission()`) and return early if it returns a response; use `require_all_permissions_json()` for AJAX/JSON endpoints

Applied to files:

  • netbox_librenms_plugin/views/sync/device_fields.py
📚 Learning: 2026-03-03T13:24:38.082Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/sync.instructions.md:0-0
Timestamp: 2026-03-03T13:24:38.082Z
Learning: Applies to **/librenms_sync.js : JavaScript VLAN modal functions must implement `openVlanDetailModal()`, `verifyVlanInGroup()`, `verifyVlanSyncGroup()` for per-interface VLAN detail editing.

Applied to files:

  • netbox_librenms_plugin/views/sync/device_fields.py
  • netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html
📚 Learning: 2026-03-03T13:23:33.731Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-03-03T13:23:33.731Z
Learning: Applies to netbox_librenms_plugin/**/*.py : Always use exact-only matching (not fuzzy) for site, platform, device type, and role via functions in `utils.py` (`find_matching_site`, `match_librenms_hardware_to_device_type`, `find_matching_platform`)

Applied to files:

  • netbox_librenms_plugin/views/sync/device_fields.py
📚 Learning: 2026-03-03T13:24:38.082Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/sync.instructions.md:0-0
Timestamp: 2026-03-03T13:24:38.082Z
Learning: Applies to **/librenms_sync.js : JavaScript verification functions must include `handleInterfaceChange()`, `handleCableChange()`, `handleVRFChange()` that POST to single-item verify endpoints to validate resource changes.

Applied to files:

  • netbox_librenms_plugin/views/sync/device_fields.py
📚 Learning: 2026-03-03T13:24:00.657Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/background-jobs.instructions.md:0-0
Timestamp: 2026-03-03T13:24:00.657Z
Learning: Applies to **/views/imports/** : `BulkImportDevicesView` (POST) must execute import: background mode enqueues `ImportDevicesJob`; sync mode calls `bulk_import_devices()` + `bulk_import_vms()` and returns OOB row swaps with `HX-Trigger: closeModal`

Applied to files:

  • netbox_librenms_plugin/views/sync/device_fields.py
📚 Learning: 2026-03-03T13:23:33.731Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-03-03T13:23:33.731Z
Learning: Applies to netbox_librenms_plugin/**/*.py : Reuse `librenms_api.py` client for all LibreNMS communication instead of making direct `requests` calls; it handles multi-server configs via `LibreNMSSettings` model and caching

Applied to files:

  • netbox_librenms_plugin/views/sync/device_fields.py
📚 Learning: 2026-03-03T13:23:33.731Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-03-03T13:23:33.731Z
Learning: Applies to netbox_librenms_plugin/views/**/*.py : Declare `required_object_permissions` as a dict mapping HTTP methods to `[(action, Model)]` tuples for NetBox model operations; some views may set this dynamically per-request

Applied to files:

  • netbox_librenms_plugin/views/sync/device_fields.py
📚 Learning: 2026-03-03T13:24:51.364Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/testing.instructions.md:0-0
Timestamp: 2026-03-03T13:24:51.364Z
Learning: Applies to tests/test_permissions.py : For `NetBoxObjectPermissionMixin` tests, set `required_object_permissions` on the instance before calling `check_object_permissions()`.

Applied to files:

  • netbox_librenms_plugin/views/sync/device_fields.py
📚 Learning: 2026-03-03T13:23:33.731Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-03-03T13:23:33.731Z
Learning: Applies to netbox_librenms_plugin/api/views.py : API endpoints must use `LibreNMSPluginPermission` class in `api/views.py` where GET checks `view_librenmssettings` and other methods check `change_librenmssettings`

Applied to files:

  • netbox_librenms_plugin/views/sync/device_fields.py
📚 Learning: 2026-03-03T13:23:33.731Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-03-03T13:23:33.731Z
Learning: Applies to netbox_librenms_plugin/views/**/*.py : All views must inherit `LibreNMSPermissionMixin` from `views/mixins.py` with `permission_required = PERM_VIEW_PLUGIN`

Applied to files:

  • netbox_librenms_plugin/views/sync/device_fields.py
📚 Learning: 2026-03-03T13:23:33.731Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-03-03T13:23:33.731Z
Learning: Applies to netbox_librenms_plugin/views/**/*.py : New views must extend the closest base class and compose mixins from `views/mixins.py` (`LibreNMSPermissionMixin`, `NetBoxObjectPermissionMixin`, `LibreNMSAPIMixin`, `CacheMixin`, `VlanAssignmentMixin`)

Applied to files:

  • netbox_librenms_plugin/views/sync/device_fields.py
📚 Learning: 2026-03-03T13:24:38.082Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/sync.instructions.md:0-0
Timestamp: 2026-03-03T13:24:38.082Z
Learning: Applies to **/views/object_sync/**/*.py : Object sync view methods must create instances of concrete table views, copy the `request` object, and call `get_context_data()`. VMs must skip cables and VLANs by returning `None` from those `get_*_context()` methods.

Applied to files:

  • netbox_librenms_plugin/views/sync/device_fields.py
📚 Learning: 2026-03-03T13:24:00.657Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/background-jobs.instructions.md:0-0
Timestamp: 2026-03-03T13:24:00.657Z
Learning: Applies to **/views/imports/** : Per-device dropdown update views (`DeviceRoleUpdateView`, `DeviceClusterUpdateView`, `DeviceRackUpdateView`) must apply selection to validation state and return re-rendered row via `render_device_row()`

Applied to files:

  • netbox_librenms_plugin/views/sync/device_fields.py
📚 Learning: 2026-03-03T13:24:18.134Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/frontend.instructions.md:0-0
Timestamp: 2026-03-03T13:24:18.134Z
Learning: Applies to netbox_librenms_plugin/templates/**/_*_sync{,_content}.html : Each sync resource has two templates following a naming convention: `_<resource>_sync.html` (the tab wrapper, loaded once when the tab is selected) and `_<resource>_sync_content.html` (the HTMX-swappable inner fragment, refreshed on data changes without a full page reload). Current resources: `_interface_sync`, `_cable_sync`, `_ipaddress_sync`, `_vlan_sync`. When adding a new sync resource, create both the wrapper and content templates following this pattern.

Applied to files:

  • netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html
📚 Learning: 2026-03-03T13:24:18.134Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/frontend.instructions.md:0-0
Timestamp: 2026-03-03T13:24:18.134Z
Learning: Applies to netbox_librenms_plugin/templates/**/htmx/*.html : HTMX fragments live in `templates/netbox_librenms_plugin/htmx/` and include: `device_import_row.html` (individual import row updates), `device_validation_details.html` (expandable validation details), `device_vc_details.html` (virtual chassis member details), `bulk_import_confirm.html` (import confirmation modal content). Keep server responses and HTMX targets in sync when modifying these fragments.

Applied to files:

  • netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html
📚 Learning: 2026-03-03T13:23:33.731Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-03-03T13:23:33.731Z
Learning: Sync pipelines must follow the pattern: fetch LibreNMS data via `librenms_api.py`, cache with `CacheMixin`, build comparison tables via `tables/*.py`, and render HTMX fragments from `templates/netbox_librenms_plugin/htmx/`

Applied to files:

  • netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html
📚 Learning: 2026-03-03T13:23:33.731Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-03-03T13:23:33.731Z
Learning: Keep REST endpoint server responses and HTMX targets in sync; implement REST endpoints in `views/imports/actions.py` and HTMX fragments in `templates/netbox_librenms_plugin/htmx/`

Applied to files:

  • netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html
📚 Learning: 2026-03-03T13:24:18.134Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/frontend.instructions.md:0-0
Timestamp: 2026-03-03T13:24:18.134Z
Learning: Applies to netbox_librenms_plugin/templates/**/settings.html : `settings.html` uses a split-form pattern: two separate Django forms (`ServerConfigForm` + `ImportSettingsForm`) sharing one page, differentiated by a hidden `form_type` field (`"server_config"` or `"import_settings"`). The test-connection button is an HTMX POST to `TestLibreNMSConnectionView`, returning an inline alert fragment.

Applied to files:

  • netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html
📚 Learning: 2026-03-03T13:24:18.134Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/frontend.instructions.md:0-0
Timestamp: 2026-03-03T13:24:18.134Z
Learning: Applies to netbox_librenms_plugin/templates/**/*.html : HTMX 2.x is the primary async layer for table row updates. Table row updates must return `<tr hx-swap-oob="true">`. Avoid `outerHTML` swaps; use OOB (Out-of-Band) or targeted `innerHTML` swaps to keep table layout intact.

Applied to files:

  • netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html
📚 Learning: 2026-03-03T13:24:18.134Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/frontend.instructions.md:0-0
Timestamp: 2026-03-03T13:24:18.134Z
Learning: Applies to netbox_librenms_plugin/{templates,static}/**/*.{html,js} : Modals use Tabler (Bootstrap-like) but without `bootstrap.Modal` helpers. Buttons target the `htmx-modal-content` element and JavaScript in `librenms_import.html` toggles the wrapper. Do not reintroduce `data-bs-toggle` or duplicate modal IDs.

Applied to files:

  • netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html
📚 Learning: 2026-03-03T13:24:38.082Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/sync.instructions.md:0-0
Timestamp: 2026-03-03T13:24:38.082Z
Learning: Applies to **/librenms_sync.js : JavaScript URL and tab state management must implement `initializeTabs()`, `getDeviceIdFromUrl()`, and `setInterfaceNameFieldFromURL()` to maintain browser state and URL synchronization.

Applied to files:

  • netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html
📚 Learning: 2026-03-03T13:24:18.134Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/frontend.instructions.md:0-0
Timestamp: 2026-03-03T13:24:18.134Z
Learning: Applies to netbox_librenms_plugin/templates/**/*.html : Styling assumes Tabler defaults. Removing `table-responsive` wrappers was deliberate to prevent dropdown clipping—do not re-add them.

Applied to files:

  • netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html
🔇 Additional comments (2)
netbox_librenms_plugin/views/sync/device_fields.py (1)

285-294: Good atomic create-and-assign flow.

Wrapping Platform creation and device assignment in one transaction avoids leaving an orphaned platform behind when the later device validation/save fails.

netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html (1)

319-330: Good guard for LibreNMS' "-" sysName sentinel.

This avoids showing a bogus sync button or success state when LibreNMS returns its placeholder instead of a real hostname.

Comment thread netbox_librenms_plugin/views/sync/device_fields.py Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
netbox_librenms_plugin/static/netbox_librenms_plugin/js/librenms_sync.js (1)

615-632: ⚠️ Potential issue | 🟡 Minor

Use error.message in this catch.

This fetch branch still logs the raw error object, while the rest of this file now standardizes on error.message. Keeping that consistent makes failed override persistence easier to diagnose.

As per coding guidelines, "In catch blocks, show error.message for debugging rather than generic messages."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@netbox_librenms_plugin/static/netbox_librenms_plugin/js/librenms_sync.js`
around lines 615 - 632, The catch block for the fetch to
'/plugins/librenms_plugin/save-vlan-group-overrides/' currently logs the raw
error object; change it to log the error.message instead to match the rest of
the file. Locate the fetch call that sends device_id/deviceId and
vid_group_map/vidGroupMap and replace the catch handler's console.error call so
it outputs the error.message (e.g., console.error('Failed to persist VLAN group
overrides:', error.message)) rather than the full error object.
♻️ Duplicate comments (3)
netbox_librenms_plugin/views/sync/device_fields.py (2)

312-320: ⚠️ Potential issue | 🟡 Minor

Don't label every ValidationError as a slug collision.

This branch also catches device.full_clean(), so unrelated device validation failures are currently reported as platform-name collisions.

🩹 Minimal fix
         except ValidationError as e:
+            error_msg = e.message_dict if hasattr(e, "message_dict") else str(e)
             logger.error(
                 f"ValidationError assigning platform '{platform_name}' to device pk={pk}: {e}",
                 exc_info=True,
             )
             messages.error(
                 request,
-                f"Platform '{platform_name}' could not be created (slug collision). Try a different name.",
+                f"Failed to assign platform '{platform_name}': {error_msg}",
             )
             return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@netbox_librenms_plugin/views/sync/device_fields.py` around lines 312 - 320,
The current except block around ValidationError conflates platform creation
errors and device.full_clean() errors; separate the concerns by splitting the
try/except: wrap platform creation/assignment (the code that uses platform_name
to create or save a Platform) in its own try/except that catches ValidationError
and logs/messages the slug-collision message referencing platform_name and pk,
and wrap device.full_clean() (or any device validation calls) in a separate
try/except that logs/messages a device validation failure (include pk and the
ValidationError details). Update the logger.error and messages.error calls
accordingly so platform-specific errors only use the "slug collision" message
and device.full_clean() errors use a distinct message that reports device
validation problems.

445-449: ⚠️ Potential issue | 🟠 Major

Normalize bare string legacy IDs before the mapping checks.

The rest of the PR accepts "42" as a legacy librenms_id, but this view only wraps bare ints into {"default": ...}. Objects still storing an int-like string will fall through to “No mapping found” / “already removed,” so the orphaned mapping remains undeletable.

Also applies to: 479-483

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@netbox_librenms_plugin/views/sync/device_fields.py` around lines 445 - 449,
The code that normalizes legacy bare-integer custom field values (the block
starting with cf_value = obj.custom_field_data.get("librenms_id") and the
subsequent "Normalize legacy bare-integer" logic) only handles int types; update
it (and the equivalent second occurrence handling later in the same module) to
also detect numeric strings (e.g., isinstance(cf_value, str) and
cf_value.isdigit()) and normalize them to the same dict form (e.g., {"default":
int(cf_value)}) before the server_key membership check so legacy IDs stored as
"42" are treated the same as 42 and mapping cleanup works. Ensure you adjust
both places where cf_value is normalized (the first cf_value normalization and
the similar block around the later mapping removal logic) and keep server_key
usage unchanged.
netbox_librenms_plugin/utils.py (1)

456-507: ⚠️ Potential issue | 🟠 Major

Legacy fallback is still cross-server and string-blind.

A bare legacy librenms_id is still returned for every server_key, and find_by_librenms_id() folds that same bare-value branch into every server-specific lookup while ignoring string-backed storage like "42". In a multi-server setup, that lets server A’s legacy 42 shadow server B’s 42, and string-backed rows remain invisible until some unrelated read path normalizes them.

Also applies to: 554-573

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@netbox_librenms_plugin/utils.py` around lines 456 - 507, The function
get_librenms_device_id (and the related find_by_librenms_id code paths)
currently treat a bare integer/string librenms_id as valid for every server_key,
which lets a legacy value on one server shadow another and ignores string-backed
IDs; change the logic so a bare int or bare string is only accepted as a legacy
fallback when server_key == "default" (or whichever canonical legacy key your
plugin uses), otherwise return None; also ensure the string branch normalises
and saves only for the canonical legacy key and mirror the same server-key-aware
behaviour in find_by_librenms_id to avoid cross-server shadowing and to surface
string-backed IDs in multi-server setups.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@netbox_librenms_plugin/views/base/librenms_sync_view.py`:
- Around line 35-45: The VC fallback isn't server-aware: change the call in
librenms_sync_view (where get_librenms_sync_device(obj) is used when
self.librenms_api.get_librenms_id(obj) is falsy) to pass the active server_key
(e.g., get_librenms_sync_device(obj, server_key)), and update
get_librenms_sync_device to accept that server_key and resolve candidates by
calling LibreNMSAPI.get_librenms_id(candidate, server_key) (do not read raw CFs)
so the selected librenms_lookup_device is chosen for the correct server context
before assigning self.librenms_id and self._librenms_lookup_device.

---

Outside diff comments:
In `@netbox_librenms_plugin/static/netbox_librenms_plugin/js/librenms_sync.js`:
- Around line 615-632: The catch block for the fetch to
'/plugins/librenms_plugin/save-vlan-group-overrides/' currently logs the raw
error object; change it to log the error.message instead to match the rest of
the file. Locate the fetch call that sends device_id/deviceId and
vid_group_map/vidGroupMap and replace the catch handler's console.error call so
it outputs the error.message (e.g., console.error('Failed to persist VLAN group
overrides:', error.message)) rather than the full error object.

---

Duplicate comments:
In `@netbox_librenms_plugin/utils.py`:
- Around line 456-507: The function get_librenms_device_id (and the related
find_by_librenms_id code paths) currently treat a bare integer/string
librenms_id as valid for every server_key, which lets a legacy value on one
server shadow another and ignores string-backed IDs; change the logic so a bare
int or bare string is only accepted as a legacy fallback when server_key ==
"default" (or whichever canonical legacy key your plugin uses), otherwise return
None; also ensure the string branch normalises and saves only for the canonical
legacy key and mirror the same server-key-aware behaviour in find_by_librenms_id
to avoid cross-server shadowing and to surface string-backed IDs in multi-server
setups.

In `@netbox_librenms_plugin/views/sync/device_fields.py`:
- Around line 312-320: The current except block around ValidationError conflates
platform creation errors and device.full_clean() errors; separate the concerns
by splitting the try/except: wrap platform creation/assignment (the code that
uses platform_name to create or save a Platform) in its own try/except that
catches ValidationError and logs/messages the slug-collision message referencing
platform_name and pk, and wrap device.full_clean() (or any device validation
calls) in a separate try/except that logs/messages a device validation failure
(include pk and the ValidationError details). Update the logger.error and
messages.error calls accordingly so platform-specific errors only use the "slug
collision" message and device.full_clean() errors use a distinct message that
reports device validation problems.
- Around line 445-449: The code that normalizes legacy bare-integer custom field
values (the block starting with cf_value =
obj.custom_field_data.get("librenms_id") and the subsequent "Normalize legacy
bare-integer" logic) only handles int types; update it (and the equivalent
second occurrence handling later in the same module) to also detect numeric
strings (e.g., isinstance(cf_value, str) and cf_value.isdigit()) and normalize
them to the same dict form (e.g., {"default": int(cf_value)}) before the
server_key membership check so legacy IDs stored as "42" are treated the same as
42 and mapping cleanup works. Ensure you adjust both places where cf_value is
normalized (the first cf_value normalization and the similar block around the
later mapping removal logic) and keep server_key usage unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI (base), Organization UI (inherited)

Review profile: ASSERTIVE

Plan: Pro

Run ID: 39defe9f-0d6f-4a11-8e58-15580f18afa5

📥 Commits

Reviewing files that changed from the base of the PR and between ad11705 and cff8ca2.

📒 Files selected for processing (7)
  • netbox_librenms_plugin/import_utils/device_operations.py
  • netbox_librenms_plugin/static/netbox_librenms_plugin/js/librenms_sync.js
  • netbox_librenms_plugin/tests/test_librenms_id.py
  • netbox_librenms_plugin/tests/test_sync_interfaces.py
  • netbox_librenms_plugin/utils.py
  • netbox_librenms_plugin/views/base/librenms_sync_view.py
  • netbox_librenms_plugin/views/sync/device_fields.py
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: test-netbox (3.14)
🧰 Additional context used
📓 Path-based instructions (8)
netbox_librenms_plugin/{templates,static}/**/*.{html,js}

📄 CodeRabbit inference engine (.github/instructions/frontend.instructions.md)

netbox_librenms_plugin/{templates,static}/**/*.{html,js}: All HTMX requests and fetch() calls must include a CSRF token. The standard pattern is document.querySelector('[name=csrfmiddlewaretoken]').value (from a hidden form input). The import JS also uses getCookie('csrftoken') as a fallback — prefer the hidden input approach for consistency.
Modals use Tabler (Bootstrap-like) but without bootstrap.Modal helpers. Buttons target the htmx-modal-content element and JavaScript in librenms_import.html toggles the wrapper. Do not reintroduce data-bs-toggle or duplicate modal IDs.

Files:

  • netbox_librenms_plugin/static/netbox_librenms_plugin/js/librenms_sync.js
netbox_librenms_plugin/static/**/*.js

📄 CodeRabbit inference engine (.github/instructions/frontend.instructions.md)

netbox_librenms_plugin/static/**/*.js: Always check response.ok before processing fetch responses to catch HTTP errors. In catch blocks, show error.message for debugging rather than generic messages.
The createCacheCountdown() function is a generic countdown timer for cache expiration display.

Files:

  • netbox_librenms_plugin/static/netbox_librenms_plugin/js/librenms_sync.js
**/librenms_sync.js

📄 CodeRabbit inference engine (.github/instructions/sync.instructions.md)

**/librenms_sync.js: JavaScript in librenms_sync.js must not be wrapped in an IIFE and must use a master initializer initializeScripts() that runs on both DOMContentLoaded and htmx:afterSwap events.
JavaScript checkbox management must include functions initializeTableCheckboxes() and updateBulkActionButton() to handle multi-table checkbox selection and bulk action button state.
JavaScript TomSelect dropdown initialization must use a TOMSELECT_INIT_DELAY_MS = 100 constant and implement delayed initialization after HTMX swaps. Required initializer functions: initializeVCMemberSelect(), initializeVRFSelects(), initializeVlanGroupSelects(), initializeVlanSyncGroupSelects().
JavaScript verification functions must include handleInterfaceChange(), handleCableChange(), handleVRFChange() that POST to single-item verify endpoints to validate resource changes.
JavaScript VLAN modal functions must implement openVlanDetailModal(), verifyVlanInGroup(), verifyVlanSyncGroup() for per-interface VLAN detail editing.
JavaScript bulk operations must include functions initializeBulkEditApply() and deleteSelectedInterfaces() to handle bulk edit and delete actions.
JavaScript table filtering must implement initializeTableFilters() and filterTable() functions for client-side row filtering.
JavaScript URL and tab state management must implement initializeTabs(), getDeviceIdFromUrl(), and setInterfaceNameFieldFromURL() to maintain browser state and URL synchronization.
JavaScript cache countdown functionality must implement initializeCountdown() and initializeCountdowns() functions to display and manage cache expiration timers.
JavaScript CSRF token must be extracted via document.querySelector('[name=csrfmiddlewaretoken]').value for all POST requests.

Files:

  • netbox_librenms_plugin/static/netbox_librenms_plugin/js/librenms_sync.js
netbox_librenms_plugin/**/*.py

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

netbox_librenms_plugin/**/*.py: Hook into NetBox Django 5 plugin APIs via navigation.py, urls.py, and api/ modules under netbox_librenms_plugin/; respect NetBox plugin conventions
Use LibreNMSAPI.get_librenms_id() instead of directly accessing the librenms_id custom field when mapping Devices/VMs to LibreNMS
Reuse librenms_api.py client for all LibreNMS communication instead of making direct requests calls; it handles multi-server configs via LibreNMSSettings model and caching
Always use exact-only matching (not fuzzy) for site, platform, device type, and role via functions in utils.py (find_matching_site, match_librenms_hardware_to_device_type, find_matching_platform)
Centralize validation state mutation during import using import_validation_helpers.py for role/cluster/rack assignment, issue removal, and status recalculation
Use get_virtual_chassis_member() for port-to-member mapping and get_librenms_sync_device() for VC priority-based device selection in virtual chassis operations

Files:

  • netbox_librenms_plugin/tests/test_sync_interfaces.py
  • netbox_librenms_plugin/utils.py
  • netbox_librenms_plugin/views/base/librenms_sync_view.py
  • netbox_librenms_plugin/views/sync/device_fields.py
  • netbox_librenms_plugin/import_utils/device_operations.py
  • netbox_librenms_plugin/tests/test_librenms_id.py
netbox_librenms_plugin/views/**/*.py

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

netbox_librenms_plugin/views/**/*.py: Views must follow a three-layer structure: Base views in views/base/, Object sync views in views/object_sync/, and Sync action views in views/sync/ with shared mixins from views/mixins.py
New views must extend the closest base class and compose mixins from views/mixins.py (LibreNMSPermissionMixin, NetBoxObjectPermissionMixin, LibreNMSAPIMixin, CacheMixin, VlanAssignmentMixin)
All views must inherit LibreNMSPermissionMixin from views/mixins.py with permission_required = PERM_VIEW_PLUGIN
Declare required_object_permissions as a dict mapping HTTP methods to [(action, Model)] tuples for NetBox model operations; some views may set this dynamically per-request
Use _get_safe_redirect_url(request) to validate referrer URLs in permission checks to prevent open-redirect attacks

Files:

  • netbox_librenms_plugin/views/base/librenms_sync_view.py
  • netbox_librenms_plugin/views/sync/device_fields.py
**/views/base/**/*.py

📄 CodeRabbit inference engine (.github/instructions/sync.instructions.md)

**/views/base/**/*.py: Base view classes (BaseLibreNMSSyncView, BaseInterfaceTableView, BaseCableTableView, BaseIPAddressTableView, BaseVLANTableView) must implement the data pipeline pattern: fetch data from LibreNMS API, cache results with CacheMixin keys like librenms_{data_type}_{model_name}_{pk}, compare against NetBox objects, and render a django-tables2 table in a partial template.
Base table view classes must implement resource-specific comparison logic: interface matching by name, IP matching by address/mask, VLAN matching by VID+group, and cables by matching remote devices and checking cable status.
VlanAssignmentMixin must resolve VLAN group scope in order: Rack → Location → Site → SiteGroup → Region → Global, and must provide auto-selection of the most-specific VLAN group and lookup map building for interface and VLAN sync.

Files:

  • netbox_librenms_plugin/views/base/librenms_sync_view.py
netbox_librenms_plugin/views/sync/**/*.py

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

netbox_librenms_plugin/views/sync/**/*.py: Sync POST handlers must call require_all_permissions() (not just require_write_permission()) and return early if it returns a response; use require_all_permissions_json() for AJAX/JSON endpoints
Follow sync conventions defined in .github/instructions/sync.instructions.md for sync views, base views, tables, and sync JavaScript

Files:

  • netbox_librenms_plugin/views/sync/device_fields.py
**/views/sync/**/*.py

📄 CodeRabbit inference engine (.github/instructions/sync.instructions.md)

Sync action views must follow the pattern: check permissions with LibreNMSPermissionMixin and NetBoxObjectPermissionMixin, read selected items from request.POST.getlist('select'), load cached data using CacheMixin.get_cache_key(), apply changes inside transaction.atomic(), and redirect to the sync tab with ?tab=<resource>.

Files:

  • netbox_librenms_plugin/views/sync/device_fields.py
🧠 Learnings (46)
📚 Learning: 2026-03-03T13:24:38.082Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/sync.instructions.md:0-0
Timestamp: 2026-03-03T13:24:38.082Z
Learning: Applies to **/librenms_sync.js : JavaScript VLAN modal functions must implement `openVlanDetailModal()`, `verifyVlanInGroup()`, `verifyVlanSyncGroup()` for per-interface VLAN detail editing.

Applied to files:

  • netbox_librenms_plugin/static/netbox_librenms_plugin/js/librenms_sync.js
  • netbox_librenms_plugin/tests/test_sync_interfaces.py
  • netbox_librenms_plugin/views/sync/device_fields.py
📚 Learning: 2026-03-03T13:24:38.082Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/sync.instructions.md:0-0
Timestamp: 2026-03-03T13:24:38.082Z
Learning: Applies to **/librenms_sync.js : JavaScript verification functions must include `handleInterfaceChange()`, `handleCableChange()`, `handleVRFChange()` that POST to single-item verify endpoints to validate resource changes.

Applied to files:

  • netbox_librenms_plugin/static/netbox_librenms_plugin/js/librenms_sync.js
  • netbox_librenms_plugin/tests/test_sync_interfaces.py
  • netbox_librenms_plugin/views/sync/device_fields.py
📚 Learning: 2026-03-03T13:24:38.082Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/sync.instructions.md:0-0
Timestamp: 2026-03-03T13:24:38.082Z
Learning: Applies to **/librenms_sync.js : JavaScript bulk operations must include functions `initializeBulkEditApply()` and `deleteSelectedInterfaces()` to handle bulk edit and delete actions.

Applied to files:

  • netbox_librenms_plugin/static/netbox_librenms_plugin/js/librenms_sync.js
📚 Learning: 2026-03-03T13:24:18.134Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/frontend.instructions.md:0-0
Timestamp: 2026-03-03T13:24:18.134Z
Learning: Applies to netbox_librenms_plugin/static/**/*import*.js : The import page uses `ModalManager` class and `filterModalManager` instance—always use this reference in fetch callbacks, not undefined `modalInstance` variables.

Applied to files:

  • netbox_librenms_plugin/static/netbox_librenms_plugin/js/librenms_sync.js
📚 Learning: 2026-03-03T13:24:38.082Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/sync.instructions.md:0-0
Timestamp: 2026-03-03T13:24:38.082Z
Learning: Applies to **/librenms_sync.js : JavaScript URL and tab state management must implement `initializeTabs()`, `getDeviceIdFromUrl()`, and `setInterfaceNameFieldFromURL()` to maintain browser state and URL synchronization.

Applied to files:

  • netbox_librenms_plugin/static/netbox_librenms_plugin/js/librenms_sync.js
📚 Learning: 2026-03-03T13:23:33.731Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-03-03T13:23:33.731Z
Learning: Applies to netbox_librenms_plugin/api/**/*.py : Update API serializers in `api/serializers.py` and `api/views.py` together to prevent contract drift between models and external API consumers

Applied to files:

  • netbox_librenms_plugin/static/netbox_librenms_plugin/js/librenms_sync.js
  • netbox_librenms_plugin/tests/test_sync_interfaces.py
  • netbox_librenms_plugin/utils.py
  • netbox_librenms_plugin/views/base/librenms_sync_view.py
  • netbox_librenms_plugin/views/sync/device_fields.py
  • netbox_librenms_plugin/tests/test_librenms_id.py
📚 Learning: 2026-03-03T13:24:38.082Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/sync.instructions.md:0-0
Timestamp: 2026-03-03T13:24:38.082Z
Learning: Applies to **/librenms_sync.js : JavaScript TomSelect dropdown initialization must use a `TOMSELECT_INIT_DELAY_MS = 100` constant and implement delayed initialization after HTMX swaps. Required initializer functions: `initializeVCMemberSelect()`, `initializeVRFSelects()`, `initializeVlanGroupSelects()`, `initializeVlanSyncGroupSelects()`.

Applied to files:

  • netbox_librenms_plugin/static/netbox_librenms_plugin/js/librenms_sync.js
📚 Learning: 2026-03-03T13:24:18.134Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/frontend.instructions.md:0-0
Timestamp: 2026-03-03T13:24:18.134Z
Learning: Applies to netbox_librenms_plugin/{templates,static}/**/*.{html,js} : Modals use Tabler (Bootstrap-like) but without `bootstrap.Modal` helpers. Buttons target the `htmx-modal-content` element and JavaScript in `librenms_import.html` toggles the wrapper. Do not reintroduce `data-bs-toggle` or duplicate modal IDs.

Applied to files:

  • netbox_librenms_plugin/static/netbox_librenms_plugin/js/librenms_sync.js
📚 Learning: 2026-03-03T13:24:18.134Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/frontend.instructions.md:0-0
Timestamp: 2026-03-03T13:24:18.134Z
Learning: Applies to netbox_librenms_plugin/templates/**/htmx/*.html : HTMX fragments live in `templates/netbox_librenms_plugin/htmx/` and include: `device_import_row.html` (individual import row updates), `device_validation_details.html` (expandable validation details), `device_vc_details.html` (virtual chassis member details), `bulk_import_confirm.html` (import confirmation modal content). Keep server responses and HTMX targets in sync when modifying these fragments.

Applied to files:

  • netbox_librenms_plugin/static/netbox_librenms_plugin/js/librenms_sync.js
📚 Learning: 2026-03-03T13:24:38.082Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/sync.instructions.md:0-0
Timestamp: 2026-03-03T13:24:38.082Z
Learning: Applies to **/librenms_sync.js : JavaScript checkbox management must include functions `initializeTableCheckboxes()` and `updateBulkActionButton()` to handle multi-table checkbox selection and bulk action button state.

Applied to files:

  • netbox_librenms_plugin/static/netbox_librenms_plugin/js/librenms_sync.js
📚 Learning: 2026-03-03T13:24:38.082Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/sync.instructions.md:0-0
Timestamp: 2026-03-03T13:24:38.082Z
Learning: Applies to **/librenms_sync.js : JavaScript table filtering must implement `initializeTableFilters()` and `filterTable()` functions for client-side row filtering.

Applied to files:

  • netbox_librenms_plugin/static/netbox_librenms_plugin/js/librenms_sync.js
📚 Learning: 2026-03-03T13:23:33.731Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-03-03T13:23:33.731Z
Learning: Applies to netbox_librenms_plugin/views/sync/**/*.py : Follow sync conventions defined in `.github/instructions/sync.instructions.md` for sync views, base views, tables, and sync JavaScript

Applied to files:

  • netbox_librenms_plugin/tests/test_sync_interfaces.py
  • netbox_librenms_plugin/views/base/librenms_sync_view.py
  • netbox_librenms_plugin/views/sync/device_fields.py
📚 Learning: 2026-03-03T13:24:51.364Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/testing.instructions.md:0-0
Timestamp: 2026-03-03T13:24:51.364Z
Learning: Applies to tests/test_netbox_librenms_plugin.py : `test_netbox_librenms_plugin.py` is an empty placeholder — do not add tests there.

Applied to files:

  • netbox_librenms_plugin/tests/test_sync_interfaces.py
  • netbox_librenms_plugin/tests/test_librenms_id.py
📚 Learning: 2026-03-03T13:24:51.364Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/testing.instructions.md:0-0
Timestamp: 2026-03-03T13:24:51.364Z
Learning: Applies to tests/**/*.py : Patch deferred/inline imports at their source module (e.g., `netbox_librenms_plugin.import_utils.process_device_filters`), not the consuming module.

Applied to files:

  • netbox_librenms_plugin/tests/test_sync_interfaces.py
  • netbox_librenms_plugin/tests/test_librenms_id.py
📚 Learning: 2026-03-03T13:24:51.364Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/testing.instructions.md:0-0
Timestamp: 2026-03-03T13:24:51.364Z
Learning: Applies to tests/**/*.py : Reuse fixtures from `tests/conftest.py` instead of creating ad-hoc mocks: Configuration (`mock_multi_server_config`, `mock_legacy_config`), API client (`mock_librenms_api`), NetBox objects (`mock_netbox_device`, `mock_netbox_vm`, `mock_netbox_site`, `mock_netbox_platform`, `mock_netbox_device_type`, `mock_netbox_device_role`, `mock_netbox_cluster`, `mock_netbox_rack`), HTTP responses (`mock_response_factory`, `mock_success_response`, `mock_device_response`, `mock_error_response`, `mock_auth_error_response`), and Import workflow (`sample_librenms_device`, `sample_librenms_device_minimal`, `sample_validation_state`, `sample_validation_state_vm`).

Applied to files:

  • netbox_librenms_plugin/tests/test_sync_interfaces.py
  • netbox_librenms_plugin/tests/test_librenms_id.py
📚 Learning: 2026-03-03T13:24:18.134Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/frontend.instructions.md:0-0
Timestamp: 2026-03-03T13:24:18.134Z
Learning: Applies to netbox_librenms_plugin/templates/**/_*_sync{,_content}.html : Each sync resource has two templates following a naming convention: `_<resource>_sync.html` (the tab wrapper, loaded once when the tab is selected) and `_<resource>_sync_content.html` (the HTMX-swappable inner fragment, refreshed on data changes without a full page reload). Current resources: `_interface_sync`, `_cable_sync`, `_ipaddress_sync`, `_vlan_sync`. When adding a new sync resource, create both the wrapper and content templates following this pattern.

Applied to files:

  • netbox_librenms_plugin/tests/test_sync_interfaces.py
📚 Learning: 2026-03-03T13:23:33.731Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-03-03T13:23:33.731Z
Learning: Applies to netbox_librenms_plugin/**/*.py : Use `get_virtual_chassis_member()` for port-to-member mapping and `get_librenms_sync_device()` for VC priority-based device selection in virtual chassis operations

Applied to files:

  • netbox_librenms_plugin/tests/test_sync_interfaces.py
  • netbox_librenms_plugin/utils.py
  • netbox_librenms_plugin/views/base/librenms_sync_view.py
  • netbox_librenms_plugin/views/sync/device_fields.py
  • netbox_librenms_plugin/import_utils/device_operations.py
📚 Learning: 2026-03-03T13:24:51.364Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/testing.instructions.md:0-0
Timestamp: 2026-03-03T13:24:51.364Z
Learning: Applies to tests/**/*.py : Patch `cache` where imported: `netbox_librenms_plugin.views.imports.list.cache`, not `django.core.cache.cache`.

Applied to files:

  • netbox_librenms_plugin/tests/test_sync_interfaces.py
  • netbox_librenms_plugin/tests/test_librenms_id.py
📚 Learning: 2026-03-03T13:24:51.364Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/testing.instructions.md:0-0
Timestamp: 2026-03-03T13:24:51.364Z
Learning: Applies to tests/**/*.py : Mock NetBox models (Device, Job, User) with `MagicMock()` instead of creating real instances.

Applied to files:

  • netbox_librenms_plugin/tests/test_sync_interfaces.py
  • netbox_librenms_plugin/tests/test_librenms_id.py
📚 Learning: 2026-03-03T13:23:33.731Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-03-03T13:23:33.731Z
Learning: Applies to netbox_librenms_plugin/**/*.py : Reuse `librenms_api.py` client for all LibreNMS communication instead of making direct `requests` calls; it handles multi-server configs via `LibreNMSSettings` model and caching

Applied to files:

  • netbox_librenms_plugin/tests/test_sync_interfaces.py
  • netbox_librenms_plugin/utils.py
  • netbox_librenms_plugin/views/base/librenms_sync_view.py
  • netbox_librenms_plugin/views/sync/device_fields.py
  • netbox_librenms_plugin/import_utils/device_operations.py
  • netbox_librenms_plugin/tests/test_librenms_id.py
📚 Learning: 2026-03-03T13:23:33.731Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-03-03T13:23:33.731Z
Learning: Applies to netbox_librenms_plugin/**/*.py : Always use exact-only matching (not fuzzy) for site, platform, device type, and role via functions in `utils.py` (`find_matching_site`, `match_librenms_hardware_to_device_type`, `find_matching_platform`)

Applied to files:

  • netbox_librenms_plugin/tests/test_sync_interfaces.py
  • netbox_librenms_plugin/utils.py
  • netbox_librenms_plugin/views/base/librenms_sync_view.py
  • netbox_librenms_plugin/views/sync/device_fields.py
  • netbox_librenms_plugin/import_utils/device_operations.py
  • netbox_librenms_plugin/tests/test_librenms_id.py
📚 Learning: 2026-03-03T13:23:33.731Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-03-03T13:23:33.731Z
Learning: Applies to netbox_librenms_plugin/**/*.py : Use `LibreNMSAPI.get_librenms_id()` instead of directly accessing the `librenms_id` custom field when mapping Devices/VMs to LibreNMS

Applied to files:

  • netbox_librenms_plugin/utils.py
  • netbox_librenms_plugin/views/base/librenms_sync_view.py
  • netbox_librenms_plugin/views/sync/device_fields.py
  • netbox_librenms_plugin/import_utils/device_operations.py
  • netbox_librenms_plugin/tests/test_librenms_id.py
📚 Learning: 2026-03-03T13:23:33.731Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-03-03T13:23:33.731Z
Learning: Applies to netbox_librenms_plugin/**/*.py : Hook into NetBox Django 5 plugin APIs via `navigation.py`, `urls.py`, and `api/` modules under `netbox_librenms_plugin/`; respect NetBox plugin conventions

Applied to files:

  • netbox_librenms_plugin/utils.py
  • netbox_librenms_plugin/views/base/librenms_sync_view.py
  • netbox_librenms_plugin/tests/test_librenms_id.py
📚 Learning: 2026-03-03T13:23:33.731Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-03-03T13:23:33.731Z
Learning: Applies to netbox_librenms_plugin/**/*.py : Centralize validation state mutation during import using `import_validation_helpers.py` for role/cluster/rack assignment, issue removal, and status recalculation

Applied to files:

  • netbox_librenms_plugin/utils.py
  • netbox_librenms_plugin/views/sync/device_fields.py
  • netbox_librenms_plugin/import_utils/device_operations.py
  • netbox_librenms_plugin/tests/test_librenms_id.py
📚 Learning: 2026-03-03T13:23:33.731Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-03-03T13:23:33.731Z
Learning: Applies to netbox_librenms_plugin/jobs.py : Follow background job conventions defined in `.github/instructions/background-jobs.instructions.md` for `jobs.py`, import views, and import utilities

Applied to files:

  • netbox_librenms_plugin/utils.py
📚 Learning: 2026-03-03T13:23:33.731Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-03-03T13:23:33.731Z
Learning: Applies to netbox_librenms_plugin/views/**/*.py : Views must follow a three-layer structure: Base views in `views/base/`, Object sync views in `views/object_sync/`, and Sync action views in `views/sync/` with shared mixins from `views/mixins.py`

Applied to files:

  • netbox_librenms_plugin/views/base/librenms_sync_view.py
  • netbox_librenms_plugin/views/sync/device_fields.py
📚 Learning: 2026-03-03T13:24:38.082Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/sync.instructions.md:0-0
Timestamp: 2026-03-03T13:24:38.082Z
Learning: Applies to **/views/base/**/*.py|**/views/object_sync/**/*.py : Use `LibreNMSAPIMixin` to provide lazy-loaded `LibreNMSAPI` instance via `self.librenms_api` property and `get_server_info()` method for template context.

Applied to files:

  • netbox_librenms_plugin/views/base/librenms_sync_view.py
  • netbox_librenms_plugin/import_utils/device_operations.py
📚 Learning: 2026-03-03T13:23:33.731Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-03-03T13:23:33.731Z
Learning: Applies to netbox_librenms_plugin/api/views.py : API endpoints must use `LibreNMSPluginPermission` class in `api/views.py` where GET checks `view_librenmssettings` and other methods check `change_librenmssettings`

Applied to files:

  • netbox_librenms_plugin/views/base/librenms_sync_view.py
  • netbox_librenms_plugin/views/sync/device_fields.py
📚 Learning: 2026-03-03T13:24:18.134Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/frontend.instructions.md:0-0
Timestamp: 2026-03-03T13:24:18.134Z
Learning: Applies to netbox_librenms_plugin/{templates,tables}/**/*.{html,py} : Templates live in `templates/netbox_librenms_plugin/`; reuse/includes under `inc/`. Sync pages extend `librenms_sync_base.html`. Tables emit HTMX-enabled columns and buttons (`tables/*.py`), so prefer updating the table renderer in Python rather than templates when changing row actions.

Applied to files:

  • netbox_librenms_plugin/views/base/librenms_sync_view.py
  • netbox_librenms_plugin/views/sync/device_fields.py
📚 Learning: 2026-03-03T13:24:38.082Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/sync.instructions.md:0-0
Timestamp: 2026-03-03T13:24:38.082Z
Learning: Applies to **/views/sync/**/*.py : Sync action views must follow the pattern: check permissions with `LibreNMSPermissionMixin` and `NetBoxObjectPermissionMixin`, read selected items from `request.POST.getlist('select')`, load cached data using `CacheMixin.get_cache_key()`, apply changes inside `transaction.atomic()`, and redirect to the sync tab with `?tab=<resource>`.

Applied to files:

  • netbox_librenms_plugin/views/base/librenms_sync_view.py
  • netbox_librenms_plugin/views/sync/device_fields.py
📚 Learning: 2026-03-03T13:24:18.134Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/frontend.instructions.md:0-0
Timestamp: 2026-03-03T13:24:18.134Z
Learning: Applies to netbox_librenms_plugin/templates/**/settings.html : `settings.html` uses a split-form pattern: two separate Django forms (`ServerConfigForm` + `ImportSettingsForm`) sharing one page, differentiated by a hidden `form_type` field (`"server_config"` or `"import_settings"`). The test-connection button is an HTMX POST to `TestLibreNMSConnectionView`, returning an inline alert fragment.

Applied to files:

  • netbox_librenms_plugin/views/base/librenms_sync_view.py
📚 Learning: 2026-03-03T13:23:33.731Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-03-03T13:23:33.731Z
Learning: Applies to netbox_librenms_plugin/models.py : Coordinate schema changes through Django migrations in `migrations/` directory; update `models.py`, admin, and Pydantic representations accordingly

Applied to files:

  • netbox_librenms_plugin/views/sync/device_fields.py
📚 Learning: 2026-03-03T13:23:33.731Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-03-03T13:23:33.731Z
Learning: Applies to netbox_librenms_plugin/views/sync/**/*.py : Sync POST handlers must call `require_all_permissions()` (not just `require_write_permission()`) and return early if it returns a response; use `require_all_permissions_json()` for AJAX/JSON endpoints

Applied to files:

  • netbox_librenms_plugin/views/sync/device_fields.py
📚 Learning: 2026-03-03T13:24:00.657Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/background-jobs.instructions.md:0-0
Timestamp: 2026-03-03T13:24:00.657Z
Learning: Applies to **/views/imports/** : `BulkImportDevicesView` (POST) must execute import: background mode enqueues `ImportDevicesJob`; sync mode calls `bulk_import_devices()` + `bulk_import_vms()` and returns OOB row swaps with `HX-Trigger: closeModal`

Applied to files:

  • netbox_librenms_plugin/views/sync/device_fields.py
  • netbox_librenms_plugin/import_utils/device_operations.py
📚 Learning: 2026-03-03T13:23:33.731Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-03-03T13:23:33.731Z
Learning: Applies to netbox_librenms_plugin/views/**/*.py : Declare `required_object_permissions` as a dict mapping HTTP methods to `[(action, Model)]` tuples for NetBox model operations; some views may set this dynamically per-request

Applied to files:

  • netbox_librenms_plugin/views/sync/device_fields.py
📚 Learning: 2026-03-03T13:24:51.364Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/testing.instructions.md:0-0
Timestamp: 2026-03-03T13:24:51.364Z
Learning: Applies to tests/test_permissions.py : For `NetBoxObjectPermissionMixin` tests, set `required_object_permissions` on the instance before calling `check_object_permissions()`.

Applied to files:

  • netbox_librenms_plugin/views/sync/device_fields.py
📚 Learning: 2026-03-03T13:23:33.731Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-03-03T13:23:33.731Z
Learning: Applies to netbox_librenms_plugin/views/**/*.py : All views must inherit `LibreNMSPermissionMixin` from `views/mixins.py` with `permission_required = PERM_VIEW_PLUGIN`

Applied to files:

  • netbox_librenms_plugin/views/sync/device_fields.py
📚 Learning: 2026-03-03T13:23:33.731Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-03-03T13:23:33.731Z
Learning: Applies to netbox_librenms_plugin/views/**/*.py : New views must extend the closest base class and compose mixins from `views/mixins.py` (`LibreNMSPermissionMixin`, `NetBoxObjectPermissionMixin`, `LibreNMSAPIMixin`, `CacheMixin`, `VlanAssignmentMixin`)

Applied to files:

  • netbox_librenms_plugin/views/sync/device_fields.py
📚 Learning: 2026-03-03T13:24:38.082Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/sync.instructions.md:0-0
Timestamp: 2026-03-03T13:24:38.082Z
Learning: Applies to **/views/object_sync/**/*.py : Object sync view methods must create instances of concrete table views, copy the `request` object, and call `get_context_data()`. VMs must skip cables and VLANs by returning `None` from those `get_*_context()` methods.

Applied to files:

  • netbox_librenms_plugin/views/sync/device_fields.py
📚 Learning: 2026-03-03T13:24:00.657Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/background-jobs.instructions.md:0-0
Timestamp: 2026-03-03T13:24:00.657Z
Learning: Applies to **/views/imports/** : Per-device dropdown update views (`DeviceRoleUpdateView`, `DeviceClusterUpdateView`, `DeviceRackUpdateView`) must apply selection to validation state and return re-rendered row via `render_device_row()`

Applied to files:

  • netbox_librenms_plugin/views/sync/device_fields.py
📚 Learning: 2026-03-03T13:24:00.657Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/background-jobs.instructions.md:0-0
Timestamp: 2026-03-03T13:24:00.657Z
Learning: Applies to **/jobs.py : `ImportDevicesJob` background job: imports devices and VMs. Must call `bulk_import_devices_shared()` for devices and `bulk_import_vms()` for VMs. `job.data` keys must include `imported_device_pks`, `imported_vm_pks`, `imported_libre_device_ids`, `imported_libre_vm_ids`, `server_key`, `total`, `success_count`, `failed_count`, `skipped_count`, `virtual_chassis_created`, `errors`, `completed`

Applied to files:

  • netbox_librenms_plugin/import_utils/device_operations.py
📚 Learning: 2026-03-03T13:24:00.657Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/background-jobs.instructions.md:0-0
Timestamp: 2026-03-03T13:24:00.657Z
Learning: Applies to **/import_utils.py : `validate_device_for_import(device, ...)` is the core validation function that produces validation state dict

Applied to files:

  • netbox_librenms_plugin/import_utils/device_operations.py
📚 Learning: 2026-03-03T13:24:00.657Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/background-jobs.instructions.md:0-0
Timestamp: 2026-03-03T13:24:00.657Z
Learning: Applies to **/import_utils.py : `process_device_filters(filters, ...)` must fetch and validate devices from LibreNMS, returning a list

Applied to files:

  • netbox_librenms_plugin/import_utils/device_operations.py
📚 Learning: 2026-03-03T13:24:00.657Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/background-jobs.instructions.md:0-0
Timestamp: 2026-03-03T13:24:00.657Z
Learning: Applies to **/import_utils.py : `bulk_import_devices_shared(devices, user, ...)` must be the shared implementation between sync and background import

Applied to files:

  • netbox_librenms_plugin/import_utils/device_operations.py
📚 Learning: 2026-03-03T13:24:00.657Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/background-jobs.instructions.md:0-0
Timestamp: 2026-03-03T13:24:00.657Z
Learning: Applies to **/import_utils.py : `bulk_import_vms(vm_imports, user, ...)` must implement VM import

Applied to files:

  • netbox_librenms_plugin/import_utils/device_operations.py
📚 Learning: 2026-03-03T13:24:51.364Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/testing.instructions.md:0-0
Timestamp: 2026-03-03T13:24:51.364Z
Learning: Applies to tests/**/*.py : Test view decision logic by setting `view._filter_form_data = {...}` directly, not via HTTP requests.

Applied to files:

  • netbox_librenms_plugin/tests/test_librenms_id.py

Comment thread netbox_librenms_plugin/views/base/librenms_sync_view.py Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@netbox_librenms_plugin/views/sync/device_fields.py`:
- Around line 446-450: The code only normalizes bare integer custom field values
(cf_value) into the per-server dict form but misses string-backed legacy IDs
(e.g. "42"); update the normalization logic that reads
obj.custom_field_data.get("librenms_id") so it also recognizes numeric strings:
if cf_value is a str and cf_value.isdigit(), convert it to an int (or directly
to the {"default": int(cf_value)} dict) before the existing dict-check and
server_key lookup. Apply the same change to the second occurrence handling the
same cf_value logic (the block around the other check referenced in the review)
so string numeric legacy IDs are upgraded consistently.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI (base), Organization UI (inherited)

Review profile: ASSERTIVE

Plan: Pro

Run ID: d765affa-63a1-496e-b03f-e501cc591cfc

📥 Commits

Reviewing files that changed from the base of the PR and between cff8ca2 and 21c2bc9.

📒 Files selected for processing (1)
  • netbox_librenms_plugin/views/sync/device_fields.py
📜 Review details
🧰 Additional context used
📓 Path-based instructions (4)
netbox_librenms_plugin/**/*.py

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

netbox_librenms_plugin/**/*.py: Hook into NetBox Django 5 plugin APIs via navigation.py, urls.py, and api/ modules under netbox_librenms_plugin/; respect NetBox plugin conventions
Use LibreNMSAPI.get_librenms_id() instead of directly accessing the librenms_id custom field when mapping Devices/VMs to LibreNMS
Reuse librenms_api.py client for all LibreNMS communication instead of making direct requests calls; it handles multi-server configs via LibreNMSSettings model and caching
Always use exact-only matching (not fuzzy) for site, platform, device type, and role via functions in utils.py (find_matching_site, match_librenms_hardware_to_device_type, find_matching_platform)
Centralize validation state mutation during import using import_validation_helpers.py for role/cluster/rack assignment, issue removal, and status recalculation
Use get_virtual_chassis_member() for port-to-member mapping and get_librenms_sync_device() for VC priority-based device selection in virtual chassis operations

Files:

  • netbox_librenms_plugin/views/sync/device_fields.py
netbox_librenms_plugin/views/**/*.py

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

netbox_librenms_plugin/views/**/*.py: Views must follow a three-layer structure: Base views in views/base/, Object sync views in views/object_sync/, and Sync action views in views/sync/ with shared mixins from views/mixins.py
New views must extend the closest base class and compose mixins from views/mixins.py (LibreNMSPermissionMixin, NetBoxObjectPermissionMixin, LibreNMSAPIMixin, CacheMixin, VlanAssignmentMixin)
All views must inherit LibreNMSPermissionMixin from views/mixins.py with permission_required = PERM_VIEW_PLUGIN
Declare required_object_permissions as a dict mapping HTTP methods to [(action, Model)] tuples for NetBox model operations; some views may set this dynamically per-request
Use _get_safe_redirect_url(request) to validate referrer URLs in permission checks to prevent open-redirect attacks

Files:

  • netbox_librenms_plugin/views/sync/device_fields.py
netbox_librenms_plugin/views/sync/**/*.py

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

netbox_librenms_plugin/views/sync/**/*.py: Sync POST handlers must call require_all_permissions() (not just require_write_permission()) and return early if it returns a response; use require_all_permissions_json() for AJAX/JSON endpoints
Follow sync conventions defined in .github/instructions/sync.instructions.md for sync views, base views, tables, and sync JavaScript

Files:

  • netbox_librenms_plugin/views/sync/device_fields.py
**/views/sync/**/*.py

📄 CodeRabbit inference engine (.github/instructions/sync.instructions.md)

Sync action views must follow the pattern: check permissions with LibreNMSPermissionMixin and NetBoxObjectPermissionMixin, read selected items from request.POST.getlist('select'), load cached data using CacheMixin.get_cache_key(), apply changes inside transaction.atomic(), and redirect to the sync tab with ?tab=<resource>.

Files:

  • netbox_librenms_plugin/views/sync/device_fields.py
🧠 Learnings (22)
📚 Learning: 2026-03-03T13:24:38.082Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/sync.instructions.md:0-0
Timestamp: 2026-03-03T13:24:38.082Z
Learning: Applies to **/views/sync/**/*.py : Sync action views must follow the pattern: check permissions with `LibreNMSPermissionMixin` and `NetBoxObjectPermissionMixin`, read selected items from `request.POST.getlist('select')`, load cached data using `CacheMixin.get_cache_key()`, apply changes inside `transaction.atomic()`, and redirect to the sync tab with `?tab=<resource>`.

Applied to files:

  • netbox_librenms_plugin/views/sync/device_fields.py
📚 Learning: 2026-03-03T13:23:33.731Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-03-03T13:23:33.731Z
Learning: Applies to netbox_librenms_plugin/views/sync/**/*.py : Follow sync conventions defined in `.github/instructions/sync.instructions.md` for sync views, base views, tables, and sync JavaScript

Applied to files:

  • netbox_librenms_plugin/views/sync/device_fields.py
📚 Learning: 2026-03-03T13:23:33.731Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-03-03T13:23:33.731Z
Learning: Applies to netbox_librenms_plugin/api/**/*.py : Update API serializers in `api/serializers.py` and `api/views.py` together to prevent contract drift between models and external API consumers

Applied to files:

  • netbox_librenms_plugin/views/sync/device_fields.py
📚 Learning: 2026-03-03T13:23:33.731Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-03-03T13:23:33.731Z
Learning: Applies to netbox_librenms_plugin/**/*.py : Centralize validation state mutation during import using `import_validation_helpers.py` for role/cluster/rack assignment, issue removal, and status recalculation

Applied to files:

  • netbox_librenms_plugin/views/sync/device_fields.py
📚 Learning: 2026-03-03T13:23:33.731Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-03-03T13:23:33.731Z
Learning: Applies to netbox_librenms_plugin/**/*.py : Use `get_virtual_chassis_member()` for port-to-member mapping and `get_librenms_sync_device()` for VC priority-based device selection in virtual chassis operations

Applied to files:

  • netbox_librenms_plugin/views/sync/device_fields.py
📚 Learning: 2026-03-03T13:23:33.731Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-03-03T13:23:33.731Z
Learning: Applies to netbox_librenms_plugin/**/*.py : Use `LibreNMSAPI.get_librenms_id()` instead of directly accessing the `librenms_id` custom field when mapping Devices/VMs to LibreNMS

Applied to files:

  • netbox_librenms_plugin/views/sync/device_fields.py
📚 Learning: 2026-03-03T13:23:33.731Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-03-03T13:23:33.731Z
Learning: Applies to netbox_librenms_plugin/views/**/*.py : Views must follow a three-layer structure: Base views in `views/base/`, Object sync views in `views/object_sync/`, and Sync action views in `views/sync/` with shared mixins from `views/mixins.py`

Applied to files:

  • netbox_librenms_plugin/views/sync/device_fields.py
📚 Learning: 2026-03-03T13:24:18.134Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/frontend.instructions.md:0-0
Timestamp: 2026-03-03T13:24:18.134Z
Learning: Applies to netbox_librenms_plugin/{templates,tables}/**/*.{html,py} : Templates live in `templates/netbox_librenms_plugin/`; reuse/includes under `inc/`. Sync pages extend `librenms_sync_base.html`. Tables emit HTMX-enabled columns and buttons (`tables/*.py`), so prefer updating the table renderer in Python rather than templates when changing row actions.

Applied to files:

  • netbox_librenms_plugin/views/sync/device_fields.py
📚 Learning: 2026-03-03T13:23:33.731Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-03-03T13:23:33.731Z
Learning: Applies to netbox_librenms_plugin/models.py : Coordinate schema changes through Django migrations in `migrations/` directory; update `models.py`, admin, and Pydantic representations accordingly

Applied to files:

  • netbox_librenms_plugin/views/sync/device_fields.py
📚 Learning: 2026-03-03T13:23:33.731Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-03-03T13:23:33.731Z
Learning: Applies to netbox_librenms_plugin/views/sync/**/*.py : Sync POST handlers must call `require_all_permissions()` (not just `require_write_permission()`) and return early if it returns a response; use `require_all_permissions_json()` for AJAX/JSON endpoints

Applied to files:

  • netbox_librenms_plugin/views/sync/device_fields.py
📚 Learning: 2026-03-03T13:24:38.082Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/sync.instructions.md:0-0
Timestamp: 2026-03-03T13:24:38.082Z
Learning: Applies to **/librenms_sync.js : JavaScript VLAN modal functions must implement `openVlanDetailModal()`, `verifyVlanInGroup()`, `verifyVlanSyncGroup()` for per-interface VLAN detail editing.

Applied to files:

  • netbox_librenms_plugin/views/sync/device_fields.py
📚 Learning: 2026-03-03T13:23:33.731Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-03-03T13:23:33.731Z
Learning: Applies to netbox_librenms_plugin/**/*.py : Always use exact-only matching (not fuzzy) for site, platform, device type, and role via functions in `utils.py` (`find_matching_site`, `match_librenms_hardware_to_device_type`, `find_matching_platform`)

Applied to files:

  • netbox_librenms_plugin/views/sync/device_fields.py
📚 Learning: 2026-03-03T13:24:38.082Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/sync.instructions.md:0-0
Timestamp: 2026-03-03T13:24:38.082Z
Learning: Applies to **/librenms_sync.js : JavaScript verification functions must include `handleInterfaceChange()`, `handleCableChange()`, `handleVRFChange()` that POST to single-item verify endpoints to validate resource changes.

Applied to files:

  • netbox_librenms_plugin/views/sync/device_fields.py
📚 Learning: 2026-03-03T13:24:00.657Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/background-jobs.instructions.md:0-0
Timestamp: 2026-03-03T13:24:00.657Z
Learning: Applies to **/views/imports/** : `BulkImportDevicesView` (POST) must execute import: background mode enqueues `ImportDevicesJob`; sync mode calls `bulk_import_devices()` + `bulk_import_vms()` and returns OOB row swaps with `HX-Trigger: closeModal`

Applied to files:

  • netbox_librenms_plugin/views/sync/device_fields.py
📚 Learning: 2026-03-03T13:23:33.731Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-03-03T13:23:33.731Z
Learning: Applies to netbox_librenms_plugin/**/*.py : Reuse `librenms_api.py` client for all LibreNMS communication instead of making direct `requests` calls; it handles multi-server configs via `LibreNMSSettings` model and caching

Applied to files:

  • netbox_librenms_plugin/views/sync/device_fields.py
📚 Learning: 2026-03-03T13:23:33.731Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-03-03T13:23:33.731Z
Learning: Applies to netbox_librenms_plugin/views/**/*.py : Declare `required_object_permissions` as a dict mapping HTTP methods to `[(action, Model)]` tuples for NetBox model operations; some views may set this dynamically per-request

Applied to files:

  • netbox_librenms_plugin/views/sync/device_fields.py
📚 Learning: 2026-03-03T13:24:51.364Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/testing.instructions.md:0-0
Timestamp: 2026-03-03T13:24:51.364Z
Learning: Applies to tests/test_permissions.py : For `NetBoxObjectPermissionMixin` tests, set `required_object_permissions` on the instance before calling `check_object_permissions()`.

Applied to files:

  • netbox_librenms_plugin/views/sync/device_fields.py
📚 Learning: 2026-03-03T13:23:33.731Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-03-03T13:23:33.731Z
Learning: Applies to netbox_librenms_plugin/api/views.py : API endpoints must use `LibreNMSPluginPermission` class in `api/views.py` where GET checks `view_librenmssettings` and other methods check `change_librenmssettings`

Applied to files:

  • netbox_librenms_plugin/views/sync/device_fields.py
📚 Learning: 2026-03-03T13:23:33.731Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-03-03T13:23:33.731Z
Learning: Applies to netbox_librenms_plugin/views/**/*.py : All views must inherit `LibreNMSPermissionMixin` from `views/mixins.py` with `permission_required = PERM_VIEW_PLUGIN`

Applied to files:

  • netbox_librenms_plugin/views/sync/device_fields.py
📚 Learning: 2026-03-03T13:23:33.731Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-03-03T13:23:33.731Z
Learning: Applies to netbox_librenms_plugin/views/**/*.py : New views must extend the closest base class and compose mixins from `views/mixins.py` (`LibreNMSPermissionMixin`, `NetBoxObjectPermissionMixin`, `LibreNMSAPIMixin`, `CacheMixin`, `VlanAssignmentMixin`)

Applied to files:

  • netbox_librenms_plugin/views/sync/device_fields.py
📚 Learning: 2026-03-03T13:24:38.082Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/sync.instructions.md:0-0
Timestamp: 2026-03-03T13:24:38.082Z
Learning: Applies to **/views/object_sync/**/*.py : Object sync view methods must create instances of concrete table views, copy the `request` object, and call `get_context_data()`. VMs must skip cables and VLANs by returning `None` from those `get_*_context()` methods.

Applied to files:

  • netbox_librenms_plugin/views/sync/device_fields.py
📚 Learning: 2026-03-03T13:24:00.657Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/background-jobs.instructions.md:0-0
Timestamp: 2026-03-03T13:24:00.657Z
Learning: Applies to **/views/imports/** : Per-device dropdown update views (`DeviceRoleUpdateView`, `DeviceClusterUpdateView`, `DeviceRackUpdateView`) must apply selection to validation state and return re-rendered row via `render_device_row()`

Applied to files:

  • netbox_librenms_plugin/views/sync/device_fields.py
🔇 Additional comments (2)
netbox_librenms_plugin/views/sync/device_fields.py (2)

285-322: Good call wrapping platform creation and assignment in one transaction.

This removes the partial-write case where a Platform could be created even if assigning it to the device failed, and the split IntegrityError/ValidationError paths are much clearer.


425-436: Per-request permission scoping is the right fix.

Normalizing object_type before require_all_permissions() keeps device-only and VM-only editors from needing unrelated change permissions.

As per coding guidelines, "Declare required_object_permissions as a dict mapping HTTP methods to [(action, Model)] tuples for NetBox model operations; some views may set this dynamically per-request" and "Sync POST handlers must call require_all_permissions() (not just require_write_permission()) and return early if it returns a response."

Comment thread netbox_librenms_plugin/views/sync/device_fields.py Outdated
@marcinpsk
marcinpsk force-pushed the pr/librenms-id-multi-server branch from 4b012ef to 5050c88 Compare March 6, 2026 14:36
Repository owner deleted a comment from coderabbitai Bot Mar 6, 2026
@marcinpsk

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Mar 6, 2026

Copy link
Copy Markdown
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
netbox_librenms_plugin/views/object_sync/vms.py (1)

25-29: ⚠️ Potential issue | 🟠 Major

Copy the request onto VMInterfaceTableView before using self.librenms_api.

get_table() now depends on self.librenms_api.server_key, but VMLibreNMSSyncView.get_interface_context() still instantiates VMInterfaceTableView() without assigning request like the device path does. On multi-server sync pages the VM interface table can therefore resolve against the default server instead of the selected one.

Based on learnings: Object sync view methods must create instances of concrete table views, copy the request object, and call get_context_data().

Also applies to: 48-54

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@netbox_librenms_plugin/views/object_sync/vms.py` around lines 25 - 29, The VM
interface context code in VMLibreNMSSyncView's get_interface_context
instantiates VMInterfaceTableView() without copying the incoming request,
causing table.get_table()/self.librenms_api to use the default server; update
get_interface_context (and the similar block at lines 48-54) to create a
VMInterfaceTableView instance, set its request attribute to the passed-in
request (e.g., table_view.request = request) before calling
get_context_data(request, obj, interface_name_field), so the table view resolves
self.librenms_api.server_key from the selected server.
netbox_librenms_plugin/views/object_sync/devices.py (1)

119-129: ⚠️ Potential issue | 🟠 Major

Use the same VC sync device for VLAN override cache keys.

SaveVlanGroupOverridesView still keys TTL and override state off the posted device, while SingleInterfaceVerifyView normalizes VC members to the primary/sync device before reading the ports cache. On a VC member page the TTL lookup can miss and return “No cached port data” even though the interface table is already loaded. Resolve the same sync device here before calling get_cache_key() and get_vlan_overrides_key().

Based on learnings: Use get_virtual_chassis_member() for port-to-member mapping and get_librenms_sync_device() for VC priority-based device selection in virtual chassis operations.

Also applies to: 343-362

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@netbox_librenms_plugin/views/object_sync/devices.py` around lines 119 - 129,
The VC member device must be normalized to the sync/primary device before
building cache keys: in SaveVlanGroupOverridesView replace the current
primary_device selection with a call to
get_librenms_sync_device(selected_device) (or if you start from a port use
get_virtual_chassis_member(port) first to map port→member then pass that member
to get_librenms_sync_device), then use that resolved sync device when calling
get_cache_key(...) and get_vlan_overrides_key(...); apply the same change for
the similar block around lines 343-362 so both TTL and override state use the
normalized VC sync device.
♻️ Duplicate comments (3)
netbox_librenms_plugin/forms.py (1)

584-586: ⚠️ Potential issue | 🟡 Minor

Avoid making warm cache reads depend on LibreNMSAPI() construction.

This still instantiates LibreNMSAPI() before cache.get(), so a missing or invalid selected-server config prevents serving cached location choices at all. Resolve the server key cheaply first, then build the client only after a cache miss.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@netbox_librenms_plugin/forms.py` around lines 584 - 586, Resolve the server
key without instantiating LibreNMSAPI first: compute the server key cheaply
(e.g., read the selected-server config or call the helper that returns
server_key) and build the cache_key and call cache.get(cache_key); only if
cache.get returns a miss should you instantiate LibreNMSAPI() and proceed.
Update the block using the symbols LibreNMSAPI, cache_key, cache.get, and
cached_choices so the API client is created after a cache miss.
netbox_librenms_plugin/views/imports/actions.py (1)

1121-1131: ⚠️ Potential issue | 🔴 Critical

Finish migrating the rest of the conflict-resolution flow to server-aware ID helpers.

migrate_librenms_id is server-aware, but link, update, update_serial, and the LibreNMS ID collision lookup above still treat librenms_id as a scalar. On a non-default server that can overwrite an existing JSON mapping and miss conflicts against dict-backed records, so linking from a second LibreNMS server can corrupt the first server's association. Route those paths through the same server-aware helpers as well.

Based on learnings: Use LibreNMSAPI.get_librenms_id() instead of directly accessing the librenms_id custom field when mapping Devices/VMs to LibreNMS.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@netbox_librenms_plugin/views/imports/actions.py` around lines 1121 - 1131,
The conflict-resolution flow still treats librenms_id as a scalar in the "link",
"update", "update_serial" branches and in the collision lookup; change those
code paths to use the server-aware helpers (e.g. call
self.librenms_api.get_librenms_id(existing_device) instead of reading the
librenms_id custom field directly and use set_librenms_device_id /
set_librenms_vm_id when writing) so IDs become per-server dicts like
migrate_librenms_id does; ensure you use LibreNMSAPI.get_librenms_id for lookups
and the set_librenms_* helpers for writes and keep the
_save_device(existing_device) call after modifications.
netbox_librenms_plugin/tests/test_import_utils.py (1)

1345-1356: ⚠️ Potential issue | 🟠 Major

Scope _setup_librenms_id_match() to the Q-based librenms-id lookup.

The blanket filter.return_value.first.return_value = existing_device makes every Device.objects.filter(...).first() look like a librenms-id hit, so these tests still pass if the implementation falls through to hostname or serial matching instead of find_by_librenms_id().

Proposed fix
 def _setup_librenms_id_match(self, existing_device, as_vm=False):
-    """Configure mocks so that a device is found by librenms_id.
-
-    Uses return_value instead of side_effect because find_by_librenms_id()
-    calls filter() with Q objects (positional args), not keyword args.
-    """
-    if as_vm:
-        self.mock_vm.objects.filter.return_value.first.return_value = existing_device
-        self.mock_device.objects.filter.return_value.first.return_value = None
-    else:
-        self.mock_vm.objects.filter.return_value.first.return_value = None
-        self.mock_device.objects.filter.return_value.first.return_value = existing_device
+    """Configure mocks so only the librenms_id lookup returns a match."""
+    def device_filter(*args, **kwargs):
+        result = MagicMock()
+        result.first.return_value = existing_device if args else None
+        return result
+
+    if as_vm:
+        self.mock_vm.objects.filter.side_effect = device_filter
+        self.mock_device.objects.filter.return_value.first.return_value = None
+    else:
+        self.mock_vm.objects.filter.return_value.first.return_value = None
+        self.mock_device.objects.filter.side_effect = device_filter
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@netbox_librenms_plugin/tests/test_import_utils.py` around lines 1345 - 1356,
The helper _setup_librenms_id_match is too broad: instead of making every
Device/VM filter().first() return the existing_device, change
self.mock_device.objects.filter and self.mock_vm.objects.filter to use a
side_effect that inspects the positional Q args and only returns a mock whose
first.return_value is existing_device when the Q contains the librenms_id
lookup; otherwise return a mock whose first.return_value is None. Update the
side_effect logic used in _setup_librenms_id_match so tests only get a match
when the filter() call is the Q-based librenms_id lookup (affecting the mock
objects referenced in _setup_librenms_id_match).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@netbox_librenms_plugin/tables/device_status.py`:
- Around line 491-496: The button markup drops the aria-label when btn_label is
set, causing screen-readers to announce the static label text instead of the
action; change the aria_attr logic so an aria-label with the action text
(btn_title) is always rendered (use btn_title as the accessible name) and keep
including aria_attr in the button string assembly where buttons.append(...) is
built (references: btn_title, aria_attr, btn_label, buttons, btn_class).

In `@netbox_librenms_plugin/tests/test_librenms_id.py`:
- Around line 122-131: The test should assert the exact filter predicate passed
to mock_model.objects.filter so the non-default path only queries
custom_field_data__librenms_id__production; after calling
find_by_librenms_id(mock_model, 999, "production"), inspect
mock_model.objects.filter.call_args (or call_args_list) and assert the Q/filter
arg equals (or contains) the predicate targeting
custom_field_data__librenms_id__production=999 and does not include a bare
librenms_id or librenms_id__exact predicate; update the test to fetch the filter
call (e.g., call_args[0][0] or keyword args) and assert it matches
Q(custom_field_data__librenms_id__production=999).

In `@netbox_librenms_plugin/tests/test_sync_devices.py`:
- Around line 196-210: The test
TestUpdateDeviceSerialViewWiring.test_has_all_required_mixins currently only
asserts LibreNMSAPIMixin; update it to also assert that LibreNMSPermissionMixin
and NetBoxObjectPermissionMixin are in UpdateDeviceSerialView.__mro__ (import
these mixins from netbox_librenms_plugin.views.mixins alongside
LibreNMSAPIMixin) so the test matches the three-mixin inheritance pattern used
by UpdateDeviceNameView.

In `@netbox_librenms_plugin/tests/test_sync_interfaces.py`:
- Around line 134-153: The test test_sets_librenms_id_when_port_id_present
currently sets iface.cf = {"librenms_id": {"default": 1}}, so it still exercises
the old mapping-path; change the setup to start with an empty custom-field
mapping (e.g., iface.cf = {}) so the test truly verifies first-time writes call
set_librenms_device_id via view.update_interface_attributes, keeping the rest of
the test (mac_addresses, librenms_data, patches, and
mock_set.assert_called_once_with(...)) unchanged.

In `@netbox_librenms_plugin/utils.py`:
- Around line 560-582: find_by_librenms_id currently only checks for legacy
bare-integer IDs (custom_field_data__librenms_id=librenms_id) and therefore
misses legacy string-stored IDs (e.g., "42"); update find_by_librenms_id to also
OR a query that matches the string form of the ID when server_key == "default"
(e.g., custom_field_data__librenms_id=str(librenms_id)) so pre-normalized string
entries inserted via the NetBox UI are found; reference the existing functions
find_by_librenms_id, set_librenms_device_id, and get_librenms_device_id to
ensure behavior is consistent with the string normalization already handled in
set_librenms_device_id/get_librenms_device_id.

In `@netbox_librenms_plugin/views/base/librenms_sync_view.py`:
- Around line 149-158: The code assumes plugins_cfg.get("servers", {}) returns a
mapping but may be None or non-mapping; normalize it before use by checking the
value from plugins_cfg.get("servers") and replacing it with an empty dict if
it's falsy or not an instance of dict so subsequent access to
servers_config.get(...) and the legacy fallback check (sk == "default" and not
servers_config) won't raise; update the initialization of servers_config in
librenms_sync_view.py (the plugins_cfg/servers_config block) to coerce
non-mapping values to {} (mirror the defensive behavior of get_server_info()).

---

Outside diff comments:
In `@netbox_librenms_plugin/views/object_sync/devices.py`:
- Around line 119-129: The VC member device must be normalized to the
sync/primary device before building cache keys: in SaveVlanGroupOverridesView
replace the current primary_device selection with a call to
get_librenms_sync_device(selected_device) (or if you start from a port use
get_virtual_chassis_member(port) first to map port→member then pass that member
to get_librenms_sync_device), then use that resolved sync device when calling
get_cache_key(...) and get_vlan_overrides_key(...); apply the same change for
the similar block around lines 343-362 so both TTL and override state use the
normalized VC sync device.

In `@netbox_librenms_plugin/views/object_sync/vms.py`:
- Around line 25-29: The VM interface context code in VMLibreNMSSyncView's
get_interface_context instantiates VMInterfaceTableView() without copying the
incoming request, causing table.get_table()/self.librenms_api to use the default
server; update get_interface_context (and the similar block at lines 48-54) to
create a VMInterfaceTableView instance, set its request attribute to the
passed-in request (e.g., table_view.request = request) before calling
get_context_data(request, obj, interface_name_field), so the table view resolves
self.librenms_api.server_key from the selected server.

---

Duplicate comments:
In `@netbox_librenms_plugin/forms.py`:
- Around line 584-586: Resolve the server key without instantiating LibreNMSAPI
first: compute the server key cheaply (e.g., read the selected-server config or
call the helper that returns server_key) and build the cache_key and call
cache.get(cache_key); only if cache.get returns a miss should you instantiate
LibreNMSAPI() and proceed. Update the block using the symbols LibreNMSAPI,
cache_key, cache.get, and cached_choices so the API client is created after a
cache miss.

In `@netbox_librenms_plugin/tests/test_import_utils.py`:
- Around line 1345-1356: The helper _setup_librenms_id_match is too broad:
instead of making every Device/VM filter().first() return the existing_device,
change self.mock_device.objects.filter and self.mock_vm.objects.filter to use a
side_effect that inspects the positional Q args and only returns a mock whose
first.return_value is existing_device when the Q contains the librenms_id
lookup; otherwise return a mock whose first.return_value is None. Update the
side_effect logic used in _setup_librenms_id_match so tests only get a match
when the filter() call is the Q-based librenms_id lookup (affecting the mock
objects referenced in _setup_librenms_id_match).

In `@netbox_librenms_plugin/views/imports/actions.py`:
- Around line 1121-1131: The conflict-resolution flow still treats librenms_id
as a scalar in the "link", "update", "update_serial" branches and in the
collision lookup; change those code paths to use the server-aware helpers (e.g.
call self.librenms_api.get_librenms_id(existing_device) instead of reading the
librenms_id custom field directly and use set_librenms_device_id /
set_librenms_vm_id when writing) so IDs become per-server dicts like
migrate_librenms_id does; ensure you use LibreNMSAPI.get_librenms_id for lookups
and the set_librenms_* helpers for writes and keep the
_save_device(existing_device) call after modifications.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI (base), Organization UI (inherited)

Review profile: ASSERTIVE

Plan: Pro

Run ID: 0948a0f9-1e17-43f8-9f86-80d612600698

📥 Commits

Reviewing files that changed from the base of the PR and between 21c2bc9 and 134f8d7.

📒 Files selected for processing (29)
  • netbox_librenms_plugin/forms.py
  • netbox_librenms_plugin/import_utils/cache.py
  • netbox_librenms_plugin/import_utils/device_operations.py
  • netbox_librenms_plugin/librenms_api.py
  • netbox_librenms_plugin/static/netbox_librenms_plugin/js/librenms_import.js
  • netbox_librenms_plugin/static/netbox_librenms_plugin/js/librenms_sync.js
  • netbox_librenms_plugin/tables/device_status.py
  • netbox_librenms_plugin/tables/interfaces.py
  • netbox_librenms_plugin/templates/netbox_librenms_plugin/_interface_sync_content.html
  • netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_validation_details.html
  • netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html
  • netbox_librenms_plugin/tests/conftest.py
  • netbox_librenms_plugin/tests/test_import_utils.py
  • netbox_librenms_plugin/tests/test_librenms_id.py
  • netbox_librenms_plugin/tests/test_mixins.py
  • netbox_librenms_plugin/tests/test_sync_devices.py
  • netbox_librenms_plugin/tests/test_sync_interfaces.py
  • netbox_librenms_plugin/tests/test_sync_view_mismatch.py
  • netbox_librenms_plugin/urls.py
  • netbox_librenms_plugin/utils.py
  • netbox_librenms_plugin/views/__init__.py
  • netbox_librenms_plugin/views/base/interfaces_view.py
  • netbox_librenms_plugin/views/base/librenms_sync_view.py
  • netbox_librenms_plugin/views/imports/actions.py
  • netbox_librenms_plugin/views/mixins.py
  • netbox_librenms_plugin/views/object_sync/devices.py
  • netbox_librenms_plugin/views/object_sync/vms.py
  • netbox_librenms_plugin/views/sync/device_fields.py
  • netbox_librenms_plugin/views/sync/interfaces.py

Comment thread netbox_librenms_plugin/tables/device_status.py Outdated
Comment thread netbox_librenms_plugin/tests/test_librenms_id.py
Comment thread netbox_librenms_plugin/tests/test_sync_devices.py
Comment thread netbox_librenms_plugin/tests/test_sync_interfaces.py
Comment thread netbox_librenms_plugin/views/base/librenms_sync_view.py
@marcinpsk
marcinpsk force-pushed the pr/librenms-id-multi-server branch from 134f8d7 to cda90aa Compare March 6, 2026 15:30
@marcinpsk
marcinpsk force-pushed the pr/librenms-id-multi-server branch from f260db7 to b271013 Compare March 9, 2026 13:02
@marcinpsk
marcinpsk force-pushed the pr/librenms-pre-id-multi-server branch from 151ddc0 to 325953b Compare March 9, 2026 18:31
marcinpsk added 18 commits March 9, 2026 19:43
…ate, wiring tests

CreateAndAssignPlatformView:
- Add transaction.atomic() wrapping platform creation + device assignment
- Call platform.full_clean() before platform.save() to surface ValidationError
- Fetch device with select_for_update() inside transaction (TOCTOU guard)
- Add logger.exception() for both ValidationError and IntegrityError
- Better error messages (include actual error, not hard-coded 'slug collision')

New independent test files (multi-server tests excluded):
- test_view_wiring.py: MRO/mixin wiring, CacheMixin, permissions, template syntax
- test_reviewer_fixes.py: vc_member_name_pattern, XSS escape, GenerateVcMemberName,
  lazy validation api passthrough, CreateAndAssignPlatformView full_clean
- test_mixins.py: LibreNMSAPIMixin lazy init, get_server_info, CacheMixin key format
- test_sync_devices.py: AddDeviceToLibreNMSView, UpdateDeviceLocation, field view wiring
… sync delegation

list.py: Check validated-cache metadata (which includes use_sysname and
strip_domain in its key) instead of raw device cache when deciding
whether to short-circuit the background job. A naming preference change
now correctly shows the cache as cold and routes through the background
path instead of doing synchronous re-validation.

librenms_sync_view.py: Always delegate VC device resolution to
get_librenms_sync_device() instead of first checking
get_librenms_device_id(). In a partially migrated VC, a member with
an explicit per-server dict is preferred over one with a legacy
bare-int — the previous guard could pick the wrong member.

Includes 5 new tests covering both fixes.
The cherry-picked VC sync delegation tests passed server_key= to
get_librenms_sync_device(), which doesn't exist on this pre-id branch.
Replaced with tests that exercise the actual priority order on this
branch: member with librenms_id > master with IP > any member with IP.
…gement

Replace bare-integer librenms_id custom field with a per-server JSON dict
format (e.g., {"primary": 42, "secondary": 99}) to support multi-server
LibreNMS deployments.

Key changes:
- Add get/set/find/migrate helpers for server-scoped librenms_id in utils.py
- Server-aware cache keys across all sync views (interfaces, cables, VLANs, IPs)
- Legacy bare-int IDs act as universal fallback for any server_key
- Convert ID badge on sync page for migrating legacy IDs with serial verification
- RemoveServerMappingView and ConvertLegacyLibreNMSIdView with collision checks
- Bool rejection guards on all librenms_id helpers (bool is subclass of int)
- CSRF token and response.ok guard in cable verify JS/HTML
- Normalize server_key to "default" early in verify/VLAN-override views
- DoesNotExist guard on select_for_update in CreateAndAssignPlatformView
- Comprehensive tests: test_librenms_id, test_mixins, test_sync_devices,
  test_sync_interfaces, test_sync_view_mismatch, test_permissions,
  test_view_wiring smoke tests for new views
…_key in sync redirects

SingleCableVerifyView.post() now strips derived fields from cached link
data and re-enriches remote side from current NetBox state, preventing
DoesNotExist when remote devices/interfaces are deleted after caching.

Sync redirect URLs for interfaces, cables, IP addresses, and VLANs now
preserve the server_key query parameter so users return to the correct
multi-server tab.
Add regression tests for stale-field stripping and XSS escaping in
SingleCableVerifyView.post().
…t check, htmx URLs

- Add bool guard to set_librenms_device_id() input parameter
- Add bool guards to legacy-migration detection in device_operations.py
- Use find_by_librenms_id() for conflict checks in device_fields.py and
  actions.py (catches legacy bare-int owners, removes hand-rolled Q)
- Tighten legacy-ID detection in librenms_sync_view.py to require
  digit-only strings (not arbitrary non-numeric strings)
- Fix test_view_wiring.py to check SyncInterfacesView directly
- Add server_key to htmx_url for interfaces, cables, modules, IP tables
- Add server_key hidden input to cable/VLAN/IP sync form templates
- Read server_key from POST in cable/VLAN/IP sync views for correct
  cache lookups and redirects in multi-server flows
…p dedup

- Remove private _get_cache_key() from SingleIPAddressVerifyView; use
  CacheMixin.get_cache_key() with server_key from POST body so cache
  reads match the key format written by _prepare_context().
- Add server_key to handleVRFChange JS fetch body (matching other
  verify endpoints).
- Add server_key hidden input to cable verify "Sync Cable" form so
  sync/cables.py reads the correct cache namespace.
- Move django.setup() to class-scoped fixture in test_view_wiring.py.
- Add test_ip_verify.py with 6 regression tests for cache key format
  and server_key propagation.
…cy display

- Reject boolean inputs in _librenms_id_q to prevent false matches on
  ID 1/0.
- Add response.ok check to handleVRFChange JS fetch (consistency with
  other endpoints).
- Unify view.request binding in test_import_utils.py so permission
  checks and business logic use the same request object.
- Support string-digit legacy IDs in migrate_legacy_librenms_id
  (e.g. "42" → {"server": 42}).
- Fix btn_title/visual mismatch in device_status.py when
  name_sync and migration are both true.
- Guard get_librenms_device_id dict branch to only return int values
  (reject floats, lists, dicts).
- Clamp cache_ttl > 0 in all 5 sync views to prevent bogus countdown
  from negative TTLs.
- Make _build_sync_info VM-safe: use getattr for serial/device_type.
- Legacy display_name fallback in _build_id_server_info for
  single-server installs.
- Remove reflected XSS in device_fields.py error responses.
…ng, cable verify server_key, re-check locked row preconditions
…locked-row recheck, aria-labels, tests

Code fixes:
- cables.py: escape member.name in render_device_selection (XSS)
- librenms_sync.js: add server_key to handleCableChange, guard radio lookup,
  close VLAN modal only on success
- utils.py: boolean guard in find_by_librenms_id, auto_save=False skips mutation
- device_fields.py: bool/strict-digit in _normalize_librenms_mapping, recheck
  locked row preconditions, getattr for VM serial
- ip_addresses_view.py: auto_save=False in prefetch, id on template hidden input
- librenms_sync_view.py: validate did in server mapping loop
- virtual_chassis.py: validate _load_vc_member_name_pattern return
- vm_operations.py: reject boolean device_id
- device_status.py: aria-labels on icon-only buttons
- device_validation_details.html: use resolved_name

Tests:
- test_librenms_id.py: auto_save mutation tests, find_by_librenms_id bool guard
- test_vm_operations.py: boolean device_id rejection
- test_reviewer_fixes.py: _load_vc_member_name_pattern, _normalize_librenms_mapping,
  _build_all_server_mappings did validation, render_device_selection XSS
vm_operations.py was missing server_key parameter, transaction.atomic(),
and set_librenms_device_id() — using the old custom_field_data pattern
that doesn't support multi-server. bulk_import.py was missing server_key
in validate_device_for_import calls and using raw server_key parameter
instead of api.server_key in import_single_device.
- Use _resolve_naming_preferences() in bulk import sync_options instead of
  inline string equality checks (wrong key and wrong truthy value)
- Add custom_field_data__librenms_id__default key assertion to
  test_default_server_key_is_default
…alidation

- Record correct naming source ("device-{id}") when both sysName and
  hostname are empty
- Reject unsupported VM actions with explicit 400 instead of misleading 404
- Skip invalid entries (bool, None, non-numeric) in per-server ID mappings
- Guard against bool device_id before int coercion in conflict handler
@marcinpsk
marcinpsk force-pushed the pr/librenms-id-multi-server branch 2 times, most recently from 55d9d70 to 50fd794 Compare March 9, 2026 19:52
… sync delegation

list.py: Check validated-cache metadata (which includes use_sysname and
strip_domain in its key) instead of raw device cache when deciding
whether to short-circuit the background job. A naming preference change
now correctly shows the cache as cold and routes through the background
path instead of doing synchronous re-validation.

librenms_sync_view.py: Always delegate VC device resolution to
get_librenms_sync_device() instead of first checking
get_librenms_device_id(). In a partially migrated VC, a member with
an explicit per-server dict is preferred over one with a legacy
bare-int — the previous guard could pick the wrong member.

Includes 5 new tests covering both fixes.
@marcinpsk
marcinpsk force-pushed the pr/librenms-id-multi-server branch from 50fd794 to 2e52600 Compare March 9, 2026 19:58
@marcinpsk marcinpsk closed this Mar 10, 2026
marcinpsk added a commit that referenced this pull request Mar 28, 2026
- #12: Pass manufacturer as explicit parameter to _build_row/_build_table_rows;
  remove self._device_manufacturer instance attribute (hidden temporal coupling)
- #13: Eliminate second resolve_module_type loop in _build_table_rows; track
  installable flag inline during first pass via sub_row.get('module_type_id')
- #14: Pre-compute ignore_cache dict once in _build_context; pass to
  _find_transparent_indices and _collect_top_items instead of double evaluation
- #15: Fix convert_speed_to_kbps return type annotation to -> int | None
- #16: Extract _apply_rules() inner helper in apply_normalization_rules to
  eliminate duplicated regex loop between manufacturer/non-manufacturer branches
- #17/#18: Extract BaseSNMPForm with 6 shared fields; rename AddToLIbreSNMPV1V2
  -> AddToLibreSNMPV1V2 and AddToLIbreSNMPV3 -> AddToLibreSNMPV3 (typo fix);
  add backwards-compatible aliases for existing imports
- #19: Rename related_name='librenms_mappings' -> 'librenms_device_type_mappings'
  on DeviceTypeMapping.netbox_device_type and 'librenms_module_type_mappings' on
  ModuleTypeMapping.netbox_module_type to avoid ambiguity; migration 0011
- #20: Add filterset_class to all six API ViewSets (InterfaceTypeMapping,
  DeviceTypeMapping, ModuleTypeMapping, ModuleBayMapping, NormalizationRule,
  InventoryIgnoreRule)
marcinpsk added a commit that referenced this pull request May 4, 2026
- XSS (#18): escape() user-controlled error strings in bulk confirm HTML response
- XSS (#19,#20): escape() object_type param in device_fields.py HTTP responses
- Stack trace (#8): replace str(exc) with generic message in bulk import HTMX error
- Stack trace (#9): replace str(exc) with generic message in interfaces transaction error
- Stack trace (#13): replace catch-all str(e) with generic message in ip_addresses_view
- JS XSS (#4,#5): fix innerHTML spinner — store/restore full innerHTML, use DOM
  for label to avoid reinterpreting textContent as HTML
- Workflow permissions (#1-#3): add permissions: contents: read to all three workflows;
  publish-pypi job-level permissions also gains contents: read alongside id-token: write
- Devcontainer logging (#17): stop printing raw codespace name / CSRF / allowed-host
  values to stdout in devcontainer config
- URL redirect (#6,#7): add comment — _get_safe_redirect_url already validates via
  url_has_allowed_host_and_scheme; CodeQL false positive
- Lint: fix E741 ambiguous variable name in e2e test
marcinpsk added a commit that referenced this pull request May 7, 2026
* feat(inventory): modules/inventory sync tab with mapping rules

Add inventory/modules sync functionality:
- Six mapping model types: DeviceTypeMapping, ModuleTypeMapping,
  ModuleBayMapping, NormalizationRule, InventoryIgnoreRule, PlatformMapping
- Migration 0010 creating all mapping tables
- Modules sync tab on Device/VM detail pages with ENTITY-MIB inventory data
- Install, replace, and move module actions
- Mapping CRUD views with YAML bulk export for all mapping models
- LibreNMS API: get_device_transceivers() for transceiver data
- Platform matching: PlatformMapping lookup before name-exact match
- contrib/ YAML files with example mappings and rules
- Comprehensive test coverage (test_sync_modules, test_modules_view,
  test_module_replace, test_platform_mapping, test_tables_modules)

* fix tests: update db-fallback tests to match new RQ-only cancellation behavior

_is_job_cancelled now returns False on RQ/Redis unavailability instead
of falling back to DB status. Update 5 tests that were asserting the
old DB-fallback behavior:

- test_db_fallback_logs_via_module_logger_when_job_logger_none →
  test_rq_unavailable_does_not_cancel_import: RQ unavailable means
  processing continues, not cancelled
- test_job_db_fallback_stopped/errored_before_validation_loop →
  test_job_cancelled_before_validation_loop_returns_empty /
  test_rq_unavailable_job_not_cancelled_in_preloop: patch
  _is_job_cancelled directly to test early-exit behavior
- test_job_validation_loop_db_fallback_stop →
  test_job_cancelled_in_validation_loop_returns_empty: use
  _is_job_cancelled side_effect to simulate mid-loop cancellation
- test_job_rq_check_exception_uses_db_status_and_exits →
  test_rq_fetch_exception_does_not_cancel_process_filters: assert
  result has 1 device (not []) when RQ unavailable

* fix: updated tests

* Apply CR findings: code quality, test, docs, and template fixes

- Remove dead _resolve_naming_preferences from actions.py (never called)
- Unify vc_detection_enabled parsing in BulkImportConfirmView (POST+GET)
- Add ambiguity detection to get_module_types_indexed second loop
- Fix get_validated_device_cache_key doctest (wrong e3b0 hash)
- Add status=='ok' envelope check before transceivers shape validation
- Strip netbox_bay_name in ModuleBayMapping.clean()
- Use server_info.server_key in _module_sync.html refresh form
- Guard module_mismatch_modal Update Serial form on serial_conflict/installed
- Remove duplicate NoSuchJobError import in test_coverage_api2.py
- Fix _is_job_cancelled side_effect count for in-loop cancellation test
- Precompute sibling_counts in _build_table_rows to eliminate N+1 queries
- Accept sibling_counts in has_nested_name_conflict (DB fallback preserved)
- Update test_has_installable_children fake_build_row signature
- Coerce entPhysicalParentRelPos to int in siblings sort (prevent TypeError)
- Move get_queue inside try block in api/views.py sync_job_status
- Replace MD5 with SHA256 for VC domain fingerprint (FIPS compliance)
- Fix test_role_is_read_from_validation to test validation dict path
- Add test_module_replace.py to docs/development/testing.md
- Add platform_mappings.yaml row to contrib/README.md
- Fix QSFP28-DD-2X100G-LR4 typo in module_type_mappings.yaml
- Clarify librenms_id auto-create in docs/usage_tips/custom_field.md

* Apply second batch of CR findings on pr/inventory-core

- Unify librenms_id auto-create version reference to 0.4.3 in docs
- Move _LIBRENMS_JOB_NAMES to module-level constant in api/views.py
- Add status=='ok' envelope check in port handler in librenms_api.py
- Split mapping ambiguity tracking in get_module_types_indexed (separate
  mapping_seen/mapping_ambiguous so explicit mappings always win over base
  ModuleType ambiguity)
- Handle match_type=='ambiguous' in find_matching_platform caller with
  dedicated warning message about conflicting platform mappings
- Move vc_requested parse before validate_device_for_import in
  BulkImportConfirmView and pass include_vc_detection=vc_requested
- Guard Replace Module form with {% if installed_module %} in
  module_mismatch_modal.html to prevent empty module_id submission
- Add mock_db_job.status/completed assertions to api2 test
- Extend cancellation test side_effects to 4 entries to target in-loop
  check (lines 507, 534, 566, 574) in both RQ and _is_job_cancelled tests
- Assert success list in test_no_warning_when_cluster_found

* Apply CR findings batch 3: test hardening, PlatformMapping lazy import, module bay normalization, BulkExportYAMLView permissions

- test_coverage_sync_views2: patch get_object_or_404 to isolate from ORM
- test_coverage_device_fields: assert save/full_clean not called on no-match
- test_librenms_id: use exact tuple set comparison for Q branch children
- test_init: assert DB alias used in custom field creation
- utils.py: move PlatformMapping import to function scope (lazy); update all test patches to models.PlatformMapping; remove create=True
- test_platform_mapping: patch require_object_permissions instead of require_write_permission
- test_sync_modules: mock apply_normalization_rules in _match_module_bay tests
- views/base/modules_view.py: apply NormalizationRule(scope=module_bay) to candidate names in _match_module_bay
- views/mapping_views.py: BulkExportYAMLView uses NetBoxObjectPermissionMixin with view permission
- views/object_sync/devices.py: precompute has_write_permission once in get_table()

* cr: batch 4 — prefetch_related, deterministic export, test improvements

- utils.py: add prefetch_related('interfacetemplates') to ModuleType query
  and prefetch_related('netbox_module_type__interfacetemplates') to
  ModuleTypeMapping query in get_module_types_indexed() to avoid N+1
  queries in has_nested_name_conflict()
- views/mapping_views.py: add .order_by('pk') to BulkExportYAMLView filter
  for deterministic YAML export; add select_related() to all 4 export
  subclass querysets (DeviceTypeMapping, ModuleTypeMapping,
  NormalizationRule, PlatformMapping)
- tests/test_utils.py: assert exact Platform.objects.get kwargs in 2 platform
  tests; fix vc.master = None -> vc.master = master to exercise designated-
  master-without-IP branch
- tests/test_platform_mapping.py: update mock chain for .order_by(); complete
  test_returns_yaml_content_type with actual view call and content-type assertion
- tests/test_sync_modules.py: fix mock chain for .prefetch_related() in
  TestGetModuleTypesIndexed

* cr: batch 5 — normalization scoping bug, test fixes

- utils.py: fix apply_normalization_rules() else-branch to filter
  manufacturer__isnull=True so callers without manufacturer context never
  have vendor-specific rules applied to their values
- tests/test_sync_modules.py: add test_mapping_overrides_ambiguous_base_key
  to lock down the separate-ambiguous-sets behaviour in
  get_module_types_indexed(); fix test_regex_mapping_with_backreference to
  use 'Optics 0/0/0/5' (with space) so the assertion cannot pass via
  exact-name fallback — only the regex expansion path can produce a match
- tests/test_platform_mapping.py: remove dangling assertions accidentally
  left inside test_returns_200_with_empty_selection (PlatformMapping import
  and existence check now live in test_all_mapping_bulk_export_yaml_views_exist)

* cr: batch 6 — normalization rule caching, test correctness

- utils.py: add preload_normalization_rules() helper that preloads
  NormalizationRule rows for a (scope, manufacturer) combination into a
  dict keyed by (scope, manufacturer_pk_or_None); update
  apply_normalization_rules() to accept preloaded_rules kwarg and use
  preloaded lists when provided (skipping DB queries); update
  resolve_module_type() to accept norm_rules kwarg and thread it through
  to apply_normalization_rules — eliminates N+1 DB queries in
  _match_module_bay and _build_row loops
- views/base/modules_view.py: call preload_normalization_rules() in
  _build_context for both 'module_bay' and 'module_type' scopes; pass
  preloaded rules via self._norm_rules_bay/_norm_rules_type to
  _match_module_bay and _build_row respectively
- tests/test_platform_mapping.py: add test_multiple_platform_mappings_returns_ambiguous
  asserting PlatformMapping.MultipleObjectsReturned yields
  match_type='ambiguous' and that Platform.objects.get is never called
- tests/test_sync_modules.py: fix test_mapping_overrides_ambiguous_base_key
  to use distinct mapping key 'SFP-1G-LX-EXPLICIT' so 'SFP-1G-LX' is
  absent and only the explicit key is present; fix
  test_uninstalled_bay_is_skipped to add grandparent bay with installed
  module (pk=99) and assert walk continues past empty bay to return 99;
  fix test_class_scoped_mapping_preferred to pass [m_generic, m_class] so
  priority logic must actively prefer class-scoped mapping; patch
  preload_normalization_rules in tests that call _build_context directly;
  update apply_normalization_rules lambda patches to accept **kw

* fix: FPC slot int/str comparison, stale docstring, test patch targets

- _fpc_slot_matches: convert match.group(1) and parent_bay.position to int
  before comparing (Python 3: '1' == 1 is False); handle ValueError with
  safe fallbacks
- apply_normalization_rules docstring: clarify that manufacturer=None applies
  only unscoped (manufacturer__isnull=True) rules, not all scope rules
- test_modules_view._run_build_context: patch load_bay_mappings and
  get_enabled_ignore_rules at utils level instead of patching model classes
  that _build_context never references directly; remove now-unused
  mock_ignore_qs variable

* fix: surface ambiguous platform, preloaded-rules fallback, serial mismatch, transceiver ignore

- actions.py sync_platform: add explicit elif for match_type='ambiguous' so
  users get a clear conflict message instead of generic 'not found' error when
  multiple PlatformMapping rows match the same OS string (works towards #51)
- utils.py apply_normalization_rules: when preloaded_rules is provided, check
  key presence before using the dict; fall back to DB query when (scope,
  mfg_pk) or (scope, None) is absent, preventing silent omission of vendor
  rules for manufacturers not included in the preloaded dict
- utils.py match_librenms_hardware_to_device_type: update Returns docstring to
  dict | None and document the MultipleObjectsReturned → None case
- modules_view.py _apply_installed_status: drop nb_serial from the guard so a
  module with a LibreNMS serial is flagged as Serial Mismatch even when NetBox
  has no serial recorded (lnms_serial and lnms_serial != nb_serial)
- modules_view.py _collect_top_items: apply ignore-rule check to transceiver-
  synthesised items before appending, so InventoryIgnoreRules can suppress
  optics from get_device_transceivers()
- test_modules_view.py: rename test that expected the old nb_serial-required
  behavior; update assertions to Serial Mismatch + can_update_serial + can_replace
- test_modules_view.py: wrap two early-return _detect_serial_conflicts tests in
  patch(dcim.models.Module) and assert filter was never called

* fix: canonical librenms_id lookup, None guard, transceiver transparent, vc flag case

- utils.py find_by_librenms_id: strip whitespace and canonicalize leading-zero
  string IDs before building Q filters; '042' and '42 ' now resolve to
  int_value=42 / canonical_str='42' and both forms are added to the query so
  they match records stored as numeric 42 or string '42'
- modules_view.py _find_parent_container_name: use (... or '') pattern to guard
  against entPhysicalName being explicitly None in the ENTITY-MIB payload
- modules_view.py _match_module_bay: same None guard for entPhysicalName,
  entPhysicalDescr, entPhysicalClass on the item dict
- modules_view.py _collect_top_items: treat 'transparent' the same as 'skip'
  for transceiver-synthesised rows so transparent synthetic items are not
  added to top_items
- actions.py BulkImportDevicesView: normalise vc_detection_enabled flag with
  .lower() before membership test so 'ON', 'True', 'TRUE' all parse correctly,
  consistent with BulkImportConfirmView

* Apply CR batch 10 fixes

- bulk_import: check cancellation every iteration (not every 5th)
- models: always call full_clean() on save, remove update_fields guard
- tables/modules: add has_write_permission param to LibreNMSModuleTable,
  gate selection column and render_actions on it
- modules_view: normalize placeholder model/serial strings in transceiver
  merge; filter placeholder serials from inv_serials set
- api/views: skip DB status update when job is already in a terminal state
- utils: add 'ambiguous' case to find_matching_platform docstring
- utils: memoize DB fallback into preloaded_rules in apply_normalization_rules
- utils: use try/except int() instead of isdigit() for +42 style IDs
- devices: pass has_write_permission to LibreNMSModuleTable constructor
- test_modules_view: use SimpleNamespace instead of MagicMock in
  _determine_status tests for unambiguous truthiness checks
- test_sync_modules: assert checkbox HTML in selection cell; update
  test_install_module_view_not_in_base to assert public import path;
  add order-independent check for class-scoped mapping preference
- test_tables_modules: set has_write_permission=True in _make_table helper;
  add no-write-permission test case
- test_utils: assert exact kwargs on DeviceTypeMapping.objects.get and
  PlatformMapping.objects.get calls
- test_vm_operations: patch _is_job_cancelled directly instead of mutating
  job.job.status via refresh_from_db side_effect
- test_coverage_base_views2: rename test to reflect actual behavior
  (cache entry present but lacks port_id)
- test_coverage_devices: update constructor assertion to include
  has_write_permission kwarg

* Apply CR batch 11 fixes

- models: add FullCleanOnSaveMixin + clean() to InterfaceTypeMapping to
  enforce uniqueness for NULL-speed rows (SQL UNIQUE skips NULL=NULL)
- utils: expand match_librenms_hardware_to_device_type docstring to
  document all three fail-closed None cases (mapping, part_number, model
  MultipleObjectsReturned), not just the mapping-table one
- test_vm_operations: remove stale mock_job.job.status='running' from
  _run_bulk_with_mappings helper; patch _is_job_cancelled=False instead

* fix: normalize librenms_hardware/os to lowercase, fix convert_speed_to_kbps docstring

- DeviceTypeMapping.clean() and PlatformMapping.clean() now lowercase
  the stored value after stripping, preventing case-variant duplicates
  (e.g. 'IOS' and 'ios') that would cause MultipleObjectsReturned on
  __iexact lookups. Closes #51.
- Fix convert_speed_to_kbps docstring: Returns section now reads
  'int | None' to match the signature and implementation.
- Add TestDeviceTypeMappingModel tests for DeviceTypeMapping.clean()
  (strip, lowercase, blank validation).
- Add test_clean_normalizes_to_lowercase and test_clean_strips_and_lowercases
  to TestPlatformMappingModel.

* fix(js): address PR #258 review findings

- hideModal: remove all backdrops via querySelectorAll+forEach
- htmx:afterSettle: derive label from aria-labelledby, fallback to id
- initializeVlanModalSave: truncate/extract error body before display

* fix: handle unsaved manufacturer in normalization, fix JS modal/fetch issues

- utils.py: guard unsaved manufacturer (pk=None) in preload_normalization_rules
  and apply_normalization_rules to prevent ValueError on DB query
- librenms_sync.js: add id="htmx-modal-label" to module mismatch modal header
  so aria-labelledby target is preserved after innerHTML replacement
- librenms_sync.js: check response.ok before response.json() in deleteUrl fetch
  so HTTP errors surface their status instead of a parse error

* fix: type annotation, non-positive librenms_id guard, htmx label listener

- utils.py: fix convert_speed_to_kbps parameter annotation to int|None
- utils.py: guard non-positive numeric librenms_id (<=0) same as None;
  skip Q canonicalization for string '0'/'-1' after int parse
- librenms_sync.js: extract updateHtmxModalLabel(), listen at document
  level for htmx:afterSettle, call from module-replace fetch completion

* fix: use _PLACEHOLDER_VALUES for serial normalization, fix docstring

- modules_view.py: _apply_installed_status and _detect_serial_conflicts
  now use _PLACEHOLDER_VALUES set instead of only guarding against "-"
  so serials like 'unknown', 'n/a', 'na' are treated as absent
- utils.py: update convert_speed_to_kbps Args docstring to int|None

* Fix CR batch: device_type ambiguity, platform MOR, cache invalidation, ChainMap bay lookup

- device_fields.py: distinguish None (ambiguous) from failed match result;
  surface a specific error for duplicate DeviceType mappings
- utils.py: find_matching_platform returns {found:False, match_type='ambiguous'}
  on Platform.MultipleObjectsReturned instead of silently taking .first()
- modules_view.py: invalidate stale inventory cache before early-return renders
  when librenms_id is falsy or inventory fetch fails
- modules_view.py: _lookup_regex_bay_mapping iterates all ChainMap scopes
  so same-named bays in different scopes are all checked
- tests: update TestFindMatchingPlatformMultipleReturned to expect ambiguous

* Promote SKIP_TYPES and _NON_HARDWARE_CLASSES to module-level constants

Move inline set literals SKIP_TYPES and _NON_HARDWARE_CLASSES out of their
method bodies and into module scope, consistent with _PLACEHOLDER_VALUES and
other module-level constants. Rename SKIP_TYPES to _SKIP_TRANSCEIVER_TYPES
to clarify its domain.

* Fix find_matching_platform docstring and non-positive string librenms_id guard

- utils.py: broaden find_matching_platform docstring to state 'ambiguous'
  applies both to multiple PlatformMapping entries and to duplicate
  exact-name Platform rows (Platform.MultipleObjectsReturned)
- utils.py: add early-return guard in find_by_librenms_id for string
  librenms_id values that parse to <= 0 (e.g. '0', '-1', '000'), preventing
  them from reaching the Q clauses and matching stale/corrupted records

* fix: normalize placeholder serials and txr_type in modules_view

_check_ignore_rules: normalize item_serial, device_serial, and
ancestor_serial against _PLACEHOLDER_VALUES so sentinels like
'unknown'/'n/a' are treated as absent and do not trigger or
short-circuit serial_matches_device rules or require_serial_match_parent
ancestor walks.

_merge_transceiver_data: normalize txr_type against _PLACEHOLDER_VALUES
(same as model/serial) so placeholder types like 'unknown' do not bypass
the _SKIP_TRANSCEIVER_TYPES guard and produce synthetic rows with
display_model set to a placeholder string.

* Fix whitespace-only librenms_id, BUILTIN model placeholder, FPC-scope exact-bay fallback, has_write_permission in HTMX render

- utils.py: treat whitespace-only librenms_id strings as absent (return None
  after strip() when cleaned == "") so they don't reach the Q-object builder
- modules_view.py: extend transceiver backfill to also replace 'BUILTIN' model
  and serial values, not just those already in _PLACEHOLDER_VALUES
- modules_view.py: exact-name bay fallback now iterates ChainMap scopes and
  calls _fpc_slot_matches() to avoid returning the wrong-scope bay when
  duplicate bay names exist across scopes (mirrors the regex path behaviour)
- modules_view.py: add has_write_permission to all render() calls in post()
  so the Install Selected button is visible in HTMX-refreshed content

* Fix non-integer librenms_id passthrough, stale cache on missing ID, exact-mapping ChainMap scope

- utils.py: non-integer strings (e.g. 'abc') now return None in
  find_by_librenms_id() instead of falling through to build Q objects;
  changed 'except ValueError: pass' to 'except ValueError: return None'
- modules_view.py: get_context_data() re-validates the device's current
  LibreNMS ID before serving cached inventory; clears cache and returns
  empty context when the mapping has been removed since the cache was written
- modules_view.py: _lookup_exact_bay_mapping() now iterates ChainMap scopes
  and calls _fpc_slot_matches() before returning, matching the existing
  behaviour of _lookup_regex_bay_mapping() and the name-fallback path

* fix: normalize _GENERIC_CONTAINER_MODELS to lowercase; embed librenms_id in inventory cache

- modules_view: lowercase _GENERIC_CONTAINER_MODELS set and apply .lower() at
  all 5 comparison sites so values like 'builtin', 'default', 'n/a' received
  from LibreNMS in any case are treated as generic containers
- modules_view: store {'inventory': data, 'librenms_id': id} in the inventory
  cache instead of the raw list; get_context_data validates the embedded
  librenms_id against the current mapping so remapped devices never serve stale
  inventory (non-dict/legacy entries are treated as cache misses)

* fix: treat duplicate-serial module conflicts as ambiguous instead of silently overwriting

When multiple Module objects share the same serial, the old loop would
overwrite row["serial_conflict_module"] nondeterministically. Now we
group conflicts by serial and only set the move target when exactly one
candidate exists; multiple candidates set serial_conflict_ambiguous.

* fix: clear stale cache on legacy payload; ungate generic-model filter from phys class

- modules_view: get_context_data now calls cache.delete(cache_key) before
  returning when the cached payload is not the new dict format so pre-upgrade
  list-form entries are evicted and the next request regenerates fresh data
- modules_view: collapse the two-check current-item filter into a single
  'model in _GENERIC_CONTAINER_MODELS' test (removes the phys_class=='container'
  gate) so empty-model non-container items are also treated as generic
- modules_view: remove the anc_class=='container' guard from the ancestor walk
  so any inventory-class ancestor with a generic model (e.g. a 'module' row
  with model='builtin') is treated as transparent instead of blocking its
  subtree

* Remove dead vc_requested assignment left by rebase

The rebase onto pr/code-quality-fixes dropped the consumer of vc_requested
in favor of the hoisted vc_detection_enabled variable, leaving the
assignment itself as an orphan that ruff F841 flags.

* fix: VC zero-based detection all-zeros guard; align VC-perm tests with fail-closed behavior

- virtual_chassis.py: only treat stack as 0-based when positions span 0 AND a
  positive value. When every entPhysicalParentRelPos is 0 the data is invalid
  and the shift produced colliding positions (all members → slot 1); fall
  through to the per-member idx+1 fallback instead.
- test_coverage_bulk_import.py: PR #257 fails stack imports fast when the
  user lacks dcim.add_virtualchassis. Update both VC-permission tests to
  assert failure + error logging instead of silent success.

* fix: CR batch 12 — serial normalization, conflict disambiguation, modal guard, test fixes

- sync/modules.py: replace all 'serial == "-"' guards with _PLACEHOLDER_VALUES
  normalization (import from modules_view) for consistency across InstallModuleView,
  InstallBranchView, PreviewModuleReplaceView, ReplaceModuleView
- sync/modules.py: PreviewModuleReplaceView — use count() instead of .first() to
  detect ambiguous serial conflicts; pass serial_conflict_ambiguous=True to template
- sync/modules.py: ReplaceModuleView — use count() to detect ambiguous conflicts;
  return error redirect when count > 1 instead of silently picking one
- module_mismatch_modal.html: add serial_conflict_ambiguous branch (warning alert);
  gate 'Update Serial Only' and 'Replace Module' buttons when conflict is ambiguous
- tables/mappings.py: render em-dash for serial_matches_device rules in
  render_require_serial_match_parent (flag has no effect for that match type)
- test_coverage_bulk_import.py: remove xfail marker — TTL refresh is now implemented
- test_coverage_utils.py: assert PlatformMapping.objects.get called with librenms_os__iexact
- test_modules_view.py: assert row.get('serial_conflict_module') is None (not 'not in row')
- test_module_replace.py: add count.return_value to mock chain for new count() calls
- tests/e2e/test_module_install.py: extend os.environ instead of replacing in subprocess

* fix: address deferred CR findings from issues #53-#56

where can_move_from and serial_conflict_module are set. Button posts to
move_module view with conflict_module_id and target_bay_id.

valid dict from fetch_device_with_cache mock instead of None, so the
test exercises the full sync code path including cache dict population.

- save.assert_called_once() → assert_called_once_with(update_fields=
  ['status', 'completed']) in test_does_not_overwrite_completed_when_not_in_rq
- Use distinct server_key 'prod-server' in DeviceModuleTableView tests
  instead of 'default' to catch accidental default fallback
- Use permission-specific has_perm side_effect instead of return_value=True

per name (dict[str, list]) instead of keeping only the first. Resolve
mapping-based lookups by checking which bay has an installed_module when
duplicates exist — return only if unambiguous (exactly one occupied bay).

Closes #53
Closes #54
Closes #55
Closes #56

* fix: address 3 remaining CR findings from PR #50

test_coverage_actions.py: Fix false-positive test — wrong POST key
'vc_detection_enabled' replaced with the actual key 'enable_vc_detection'
that _resolve_vc_detection_enabled() reads. Previously the mock returned
None for every key and the assertion passed via the default=False fallback.

views/sync/modules.py: Unwrap cached inventory dict payload before use.
The inventory cache stores {"inventory": [...], "librenms_id": ...} but all
4 sync action views (InstallBranchView, InstallSelectedView,
ModuleMismatchPreviewView, ReplaceModuleView) iterated the raw payload as
a list, causing item.get("entPhysicalIndex") to iterate dict keys instead
of inventory rows. Added _extract_inventory_list() helper that unwraps the
dict payload and tolerates legacy list payloads.

views/sync/modules.py: Apply ignore rules to root item in _collect_branch().
Previously only children were filtered by ignore rules; the root/parent
item was always enqueued. Now _collect_branch() checks the root item:
'skip' → return []; 'transparent' → collect children only, not root.
Also updated InstallSelectedView to filter both 'skip' and 'transparent'
(was only 'skip') to match table view parity.

* refactor: drop _extract_inventory_list legacy-list fallback

The "legacy" list payload referenced in e96b3f3 never existed in any
released or upstream code — the bare-list writer was replaced inside
this same branch (7dfd436) before any consumer shipped, so no
deployed cache can hold one. The fallback also contradicted
BaseModuleTableView.get_context_data, which evicts non-dict payloads
as cache misses.

Drop the `or []` non-dict branch so the helper matches the canonical
reader, and update the inventory-cache stubs in test_module_replace.py
and test_sync_modules.py to use the dict format produced by the writer.

* fix: race conditions, class-aware mappings, stale snapshot in module sync

_install_single: Lock target bay with select_for_update() before checking
occupancy. Previously two concurrent requests could both observe the bay
as empty, causing an IntegrityError that rolled back the entire batch.
Now consistent with InstallModuleView's locking pattern.

_find_parent_module_id: Make ancestor mapping resolution class-aware.
Exact mappings are now indexed by (librenms_name, librenms_class) with
class-specific preference and class-empty fallback. Regex mappings also
prefer class-matching rules before falling back to class-empty rules,
consistent with _lookup_regex_bay_mapping() in the base view.

ReplaceModuleView: Move target_bay/old_type_name/old_bay_name reads
after select_for_update() so they reflect the locked row's current state.
Previously these were captured from the pre-lock snapshot, which could
be stale if another request moved or replaced the module first.

MoveModuleView: Lock target bay with select_for_update() inside the
atomic block instead of using the pre-lock get_object_or_404 snapshot.

Tests updated for select_for_update chains and librenms_class on mock
mapping helpers.

* fix: normalize placeholder serials in UpdateModuleSerialView, fix regex fallback in _find_parent_module_id

UpdateModuleSerialView: add placeholder normalization consistent with all
other serial-handling paths (InstallModuleView, _install_single,
ReplaceModuleView, ModuleMismatchPreviewView).

_find_parent_module_id: replace 'class_matches or fallback_matches' with
'class_matches + fallback_matches' so empty-class regex rules are still
tried when class-specific rules exist but none match the name — matching
the base view's _lookup_regex_bay_mapping pattern.

* fix: lock module row before updating serial in UpdateModuleSerialView

Move Module fetch inside transaction.atomic() with select_for_update()
to prevent stale-instance writes when a concurrent replace/move changes
the module between the read and the save. Consistent with locking pattern
used by InstallModuleView, ReplaceModuleView, and MoveModuleView.

Update test to mock Module.objects.select_for_update() chain instead of
get_object_or_404 for the module.

* fix: use fullmatch+expand in _find_parent_module_id regex matching

Mirror base view's _lookup_regex_bay_mapping behavior:
- Use rm._compiled_pattern.fullmatch(name) instead of re.search()
- Use match.expand(rm.netbox_bay_name) for capture group substitution
- Prevents false-positive partial matches and enables regex backrefs

Update test helpers to set _compiled_pattern on mock regex mappings.

* fix: address CR review batch - regex, tests, docs

- Tighten Nokia regex: \w → [0-9] for part number digits, [A-Z0-9]
  for transceiver cleanup pattern
- Use real dict for request.GET mock in test_background_jobs.py
- Make devcontainer discovery deterministic: env var override,
  error on multiple matches
- Fix brittle '156 objects' banner filter in e2e tests
- Fix typos in virtual_chassis.md (NEtbox → NetBox, dispalys)
- Make README install snippet idempotent with grep guard

* revert: restore original docs/usage_tips/virtual_chassis.md

Revert grammar/typo edits to virtual_chassis.md — these changes
do not belong in this PR. Any docs updates should go through the
upstream repository directly.

* fix: revert bulk_import cancellation, VC comment, VM docstring, sync template improvements

- Restore periodic job cancellation checks (every 5th iteration)
- Improve VC zero-based position detection comment clarity
- Simplify VM create docstring, reorder server_key parameter
- Use resolved_name instead of sysName in sync template
- Move VC serial count badge to button label
- Add name check tooltip

* fix: restore naming resolution and VC logic lost during rebase

Rebase regression stripped resolve_naming_preferences(),
_determine_device_name(), and _generate_vc_member_name() from both
UpdateDeviceNameView and the sync base view's get_librenms_device_info().

Restored:
- resolved_name computation using user naming preferences (sysName vs
  hostname, strip domain) in librenms_sync_view.py
- VC member name generation for virtual chassis devices
- VC sync device lookup for members without their own librenms_id
- resolved_name template context variable (used by sync_base template)
- Proper early bailout when LibreNMS has no usable name

Updated test mocks to set virtual_chassis=None and patch the restored
naming functions.

* revert: restore original README.md and virtual_chassis.md

These files should not be modified in this PR — docs and README
changes belong in upstream repository directly.

* fix: improve ambiguity test assertion and error message wording

- Rename test to test_match_none_returns_ambiguous_error and assert
  'Ambiguous' appears in the error message to detect regressions
- Broaden error message to cover both DeviceTypeMapping and DeviceType
  ambiguity (match_librenms_hardware_to_device_type returns None for
  either case)

* fix: CR review - modal close, test line refs, generic e2e

- librenms_sync.js: replace closeBtn.click() with hideModal() in VLAN
  modal save handler (aligns with upstream fix 7bf533f)
- test_coverage_bulk_import/devices/list: strip hardcoded line-number
  references from docstrings to avoid drift
- tests/e2e/test_module_install: make device-agnostic — auto-detect
  device, discover modules from table, generic assertions

* fix: three bugs in module inventory sync reported in PR #261 review

- select_for_update(of=("self",)) avoids FOR UPDATE on the nullable side
  of the installed_module outer join, fixing the Install Selected crash
- add has_write_permission to full-page context so Install Selected form
  renders on page load, not only after an htmx Refresh Modules swap
- guard htmx.process() with typeof check to prevent 'htmx is not defined'
  error when the Replace modal fetches its preview fragment

* fix: improve module table readability in dark mode

Remove table-success row highlight from installed modules — the Installed
badge is sufficient to identify state, and the green row background makes
linked text unreadable in dark theme.

Add text-white/text-dark contrast classes to all badge colors so they
remain legible in both light and dark themes.

* feat: split Mappings into its own navigation group

Move all mapping items (Interface, Device Type, Module Type, Module Bay,
Normalization Rules, Inventory Ignore Rules, Platform) out of the
Settings group into a dedicated Mappings group for clearer navigation.

* refactor: reorder navigation groups to Import, Status Check, Mappings, Settings

* fix: remove table-light from mismatch modal thead for dark mode

table-light forces a white background regardless of theme. Removing it
lets Bootstrap 5.3 theme-aware styling apply to the header row.

* fix: replace table-danger/warning cell backgrounds with text colors in mismatch modal

table-danger/table-warning produce a pinkish/yellow background that
clashes with Bootstrap's link color in dark mode. Switch to text-danger
and text-warning with fw-semibold — pure text color works on any
background and reads correctly in both light and dark themes.

* fix: remove all row background highlighting from module sync table

table-warning and table-danger row classes cause unreadable contrast in
dark mode. The status badge is sufficient to identify each row state.
Remove the row_class field entirely along with the dead row_attrs entry.

* refactor: reorder Mappings group — all mappings first, then Ignore Rules, Normalization Rules

* fix: update tests to reflect row_class removal

Replace assertions on table-success/danger/warning row_class values
with assert "row_class" not in row now that row highlighting is gone.

* fix: auto-generate slug when creating platform from sync page (#279)

Platform() without a slug fails full_clean() in NetBox 4.x. Derive the
slug from the platform name via slugify so the modal submit succeeds.

* Revert "fix: auto-generate slug when creating platform from sync page (#279)"

This reverts commit 3029cd3c2fc7ef054104e721a663ccde2109bdbb.

* fix: generate slug when creating Platform via CreateAndAssignPlatformView

Platform requires a slug field; omitting it caused full_clean() to raise
a ValidationError before the platform could be saved. Fixes #279.

* test: assert Platform constructor receives slug in CreateAndAssignPlatformView

Regression test for #279 — verifies slugify(name) is passed to the
Platform constructor so the missing-slug error cannot recur undetected.

* fix: wrap OOB row in table to survive HTMX HTML parsing

When AddDeviceTypeMappingView returns a combined response with both
the modal OOB (a <div>) and the row OOB (a <tr>), HTMX wraps the
response in a <template> for parsing. A <tr> following a <div> in
that context is invalid HTML and gets silently dropped by the browser
parser, so the row never updates.

Wrap row_html in <table><tbody>...</tbody></table> before appending
to ensure the <tr> element survives parsing. The <div id='django-messages'>
inside is foster-parented outside the table by the parser, so both
OOBs are found and applied correctly.

* fix(imports): add HTTP status codes, remove redundant full_clean, fix JS Content-Type case-sensitivity

- AddDeviceTypeMappingView: return 400 for missing/invalid device_type_id,
  404 for DeviceType.DoesNotExist, 500 for unexpected exceptions
- Remove redundant mapping.full_clean() before save() — DeviceTypeMapping
  inherits FullCleanOnSaveMixin which calls full_clean() inside save()
- librenms_sync.js: lowercase Content-Type header before .includes() check
  to handle case variants (e.g. 'Application/JSON')

* fix(js): extract JSON error message in delete-interfaces handler; guard updateHtmxModalLabel self-assignment

- DeleteNetBoxInterfacesView fetch: parse JSON error body (error/message/detail)
  instead of throwing raw JSON string, consistent with other fetch handlers
- updateHtmxModalLabel: skip textContent assignment when header === label to
  prevent stripping icon markup from the modal title element

* refactor(js): extract fetchErrorMessage() helper to unify fetch error handling

inline text/JSON handling. Helper extracts error/message/detail from
JSON responses, falls back to raw text, and truncates at 300 chars.
Eliminates drift between handlers and simplifies future additions.

* feat: exact-name-first platform lookup; optional mapping on platform create

- find_matching_platform(): try exact case-insensitive name match first,
  fall back to PlatformMapping only when no direct name match exists;
  update docstring accordingly
- _get_platform_info(): delegate to find_matching_platform() instead of
  inline Platform.objects.get(); use 'or "-"' for null LibreNMS fields
- CreateAndAssignPlatformView: read 'librenms_os' + 'create_mapping' POST
  params; create PlatformMapping(librenms_os, platform) when checkbox
  checked and no existing mapping for that OS
- librenms_sync_base.html: add librenms_os hidden input, 'Add platform
  mapping' checkbox, and JS warning when platform name differs from OS
- device_validation_details.html: inline device-type search form when
  no matching device type
- urls.py + views/__init__.py: register and export AddDeviceTypeMappingView
- tests: align mocks and assertions to new lookup order

* fix(pr#50): address CR feedback on AddDeviceTypeMapping form and platform-mapping creation

- device_validation_details.html: add visually-hidden <label> for the
  device-type search input (HTMLHint a11y)
- device_validation_details.html: replace hardcoded "/api/dcim/device-types/"
  with {% url 'dcim-api:devicetype-list' %} so NetBox BASE_PATH deployments
  work correctly
- CreateAndAssignPlatformView: surface PlatformMapping creation failures
  to the user via messages.warning, and inform when an existing mapping
  was found (no longer fails silently)

* fix(pr#50): isolate PlatformMapping save; ignore stale autocomplete results

- CreateAndAssignPlatformView: wrap PlatformMapping save in nested
  transaction.atomic() so an IntegrityError on the unique librenms_os
  constraint only rolls back the mapping savepoint, not the outer
  device-platform-assignment transaction (which was previously left in
  needs_rollback state, so messages.success would fire after a discarded
  device save)
- device_validation_details.html: add monotonically increasing requestSeq
  to the device-type autocomplete so out-of-order fetch responses can no
  longer overwrite the dropdown with results for an older query

* fix(pr#50): check add_platformmapping permission when create_mapping is requested

CreateAndAssignPlatformView now reads 'create_mapping' from POST before the
permission check and dynamically adds ('add', PlatformMapping) to
required_object_permissions so require_all_permissions() enforces it.
Without this, a user with only add_platform + change_device could silently
create PlatformMapping objects through the checkbox.

* fix: address CodeQL security scan findings

- XSS (#18): escape() user-controlled error strings in bulk confirm HTML response
- XSS (#19,#20): escape() object_type param in device_fields.py HTTP responses
- Stack trace (#8): replace str(exc) with generic message in bulk import HTMX error
- Stack trace (#9): replace str(exc) with generic message in interfaces transaction error
- Stack trace (#13): replace catch-all str(e) with generic message in ip_addresses_view
- JS XSS (#4,#5): fix innerHTML spinner — store/restore full innerHTML, use DOM
  for label to avoid reinterpreting textContent as HTML
- Workflow permissions (#1-#3): add permissions: contents: read to all three workflows;
  publish-pypi job-level permissions also gains contents: read alongside id-token: write
- Devcontainer logging (#17): stop printing raw codespace name / CSRF / allowed-host
  values to stdout in devcontainer config
- URL redirect (#6,#7): add comment — _get_safe_redirect_url already validates via
  url_has_allowed_host_and_scheme; CodeQL false positive
- Lint: fix E741 ambiguous variable name in e2e test

* revert: remove workflow permissions additions (tracked separately)

* Fix PR review findings: available_roles, CSRF, test quality

- bulk_import.py: preserve available_roles in elif-not-actual_is_vm branch
  (matches pattern of other clearing paths at lines 363, 384)
- librenms_sync.js: remove getCookie('csrftoken') fallback from two fetch
  call sites; align with hidden-input-only CSRF convention used elsewhere
- test_vm_operations.py: strengthen log assertion to assert_any_call with
  expected checkpoint messages (VM 1 and VM 5 of 10)
- test_vm_operations.py: delete duplicate test_job_cancellation_with_errored_status
  (identical logic already covered by test_job_cancellation_breaks_loop)
- Skip mock-target change: apply_cluster/role_to_validation are lazy (in-function)
  imports, so patching import_validation_helpers.X is correct — patching
  vm_operations.X would fail as those names don't exist at module level

* Fix PR #58 review findings (batch 2)

- librenms_sync_view.py: Fix inverted comment on find_matching_platform order
- actions.py: Replace str(exc) with generic message in non-HTMX bulk import error handler
- actions.py: AddDeviceTypeMappingView — honour server_key, add NetBoxObjectPermissionMixin
  with DeviceTypeMapping add/change permissions
- device_validation_details.html: Add hidden server_key input to dt-mapping form
- interfaces.py: Add logger.exception in DeleteNetBoxInterfacesView catch-all
- test_sync_devices.py: Strengthen VM type assertion to assert_called_once_with
  including VirtualMachine class and pk kwarg

* Fix PR #58 review findings (batch 3)

- device_validation_details.html: Disable 'Add Mapping' button until device type
  is selected from dropdown; enable on selection, re-disable on input clear
- devices.py: Use copy.copy(request) for child table view instances, consistent
  with vms.py pattern
- test_coverage_devices.py: Update request assertions to use == (copy equality)
  instead of 'is' identity to match new copy.copy behaviour

* Fix #59–#62: YAML anchor, DynamicModelChoiceField, ToggleColumn, write perms

Closes #59: Anchor Juniper MX regex pattern (^...$) so generic transceiver
pattern sorts first and MX-specific fallback works correctly.

Closes #60: Replace plain ModelChoiceField with DynamicModelChoiceField in
DeviceTypeMappingForm and ModuleTypeMappingForm for API-backed typeahead.

Closes #61: Add 'pk' to fields/default_columns in all 7 mapping tables so
NetBoxTable's built-in ToggleColumn renders the bulk-select checkbox.

Closes #62: Add LibreNMSWritePermissionMixin (permission_required =
PERM_CHANGE_PLUGIN) to mixins.py and apply it to all 35 mutation views
(Create, Edit, Delete, BulkImport, BulkDelete) in mapping_views.py.

* Fix PR #63 review findings: ordering, error handling, modal title, click listener cleanup

- utils.py: add .order_by('pk') to get_enabled_ignore_rules() for deterministic
  first-match-wins behavior in _check_ignore_rules
- utils.py: add ambiguity_source key to find_matching_platform ambiguous returns
  ('platform' or 'mapping') to distinguish which source caused the conflict
- modules_view.py: _apply_installed_status else branch returns 'No Type' instead
  of 'Installed' when matched_type is None
- actions.py: AddDeviceTypeMappingView exception handler uses logger.exception
  and returns generic message instead of leaking str(exc) to the client
- device_fields.py: separate IntegrityError from ValidationError in
  CreateAndAssignPlatformView — IntegrityError on PlatformMapping insert treated
  as mapping_existed (concurrent insert) rather than a creation failure
- librenms_sync.js: updateHtmxModalLabel scopes .modal-title query to
  #htmx-modal-body to avoid matching the outer #htmx-modal-label element
- device_validation_details.html: replace anonymous accumulating document click
  listener with a named handleDocumentClick function that removes itself when
  the dropdown element is detached from the DOM (HTMX fragment replacement)
- test_coverage_forms.py: use _make_form helper in test methods instead of
  duplicating the patch setup inline
- test_platform_mapping.py: update ambiguous assertion to include ambiguity_source
- test_sync_modules.py: fix InventoryIgnoreRule mock to support .order_by() chain
- contrib/platform_mappings.yaml: create example file referenced in README

* Fix replacement template validation and deterministic bay lookup

Closes #64, Closes #65

Issue #64: ModuleBayMapping.clean() and NormalizationRule.clean() validated
replacement templates against test strings that didn't guarantee a regex match
(empty string, or pattern text itself), so invalid back-references like \2
on a single-group pattern silently passed. Add _validate_replacement_template()
helper that builds a synthetic guaranteed-match pattern with identical group
structure (named groups preserved), exercising all back-references.

Issue #65: _build_table_rows() used ChainMap(device_bays, *module_scoped_bays.values())
for transceiver items, whose iteration order was non-deterministic (dict insertion
order = queryset order). Replace with a dedicated _compute_all_bays() static
method that sorts module IDs by PK for stable first-match-wins collision
resolution, logs collisions at DEBUG level, and always lets device-level bays
win. Remove the now-unused 'from collections import ChainMap' import.

* Fix wildcard constraint, ambiguity message, and scoped permissions

Add DB-level UniqueConstraint for wildcard InterfaceTypeMapping rows
(librenms_speed IS NULL) to prevent concurrent duplicate inserts that
bypass the in-process clean() check. The existing constraint for
non-NULL rows gains an explicit condition so both are partial indexes.
Migration 0011 handles the rename + addition atomically.

Update sync_platform ambiguous-match error message to inspect
ambiguity_source ('platform' vs 'mapping') and direct users to either
the Platforms screen or the Platform Mappings screen as appropriate.

Restructure AddDeviceTypeMappingView.post() so only the plugin write
permission is checked upfront (cheap). The API call and hardware string
extraction happen first, then an existence check on DeviceTypeMapping
determines whether 'add' or 'change' object permission is required
before the actual get_or_create.

* devcontainer: restore full debug output in codespaces-configuration.py

Development-only file; logging config values (CSRF origins, allowed hosts)
is an accepted tradeoff. CodeQL alert dismissed intentionally.

* fix(migration): add preflight dedup before wildcard UniqueConstraint

Before enforcing unique_interface_type_mapping_wildcard, remove any
duplicate librenms_speed IS NULL rows (keeping lowest PK per
librenms_type). Guards against deployments that applied 0010 and hit
the race window before 0011 was available.

* fix: remove dead check_match, close TOCTOU race, fix migration db alias

- Remove InventoryIgnoreRule.check_match() — dead code never called
  anywhere; the real matching logic with require_serial_match_parent
  lives in _check_ignore_rules() in modules_view.py
- Close add→change permission race in AddDeviceTypeMappingView: use
  select_for_update() inside transaction.atomic() and re-check change
  permission if a concurrent request created the mapping in the window
  between the upfront filter() and the write
- Use schema_editor.connection.alias for ORM reads/deletes in
  remove_wildcard_duplicates migration so multi-DB deployments clean
  up duplicates against the correct database

* fix: guard symmetric delete race in AddDeviceTypeMappingView

If existing_mapping was found upfront (change permission granted) but
the row was deleted before select_for_update(), the else branch would
create a new row without ever requiring add permission. Add a symmetric
guard: if existing_mapping and not locked, re-check add permission
before the create path runs.

* fix: catch IntegrityError on concurrent create in AddDeviceTypeMappingView

select_for_update() cannot lock absent rows, so two concurrent requests
both seeing no existing mapping can both attempt .create(). Catch
IntegrityError inside the transaction and return 409 so the client
retries; the second attempt will find the now-existing row and take the
update path with correct change-permission checking.

Also move IntegrityError import to top-level (was a deferred local
import inside _save_device).

* fix: inject hx-swap-oob on device row in AddDeviceTypeMappingView response

The <table><tbody> wrapper was already in place to keep the <tr> in a valid
HTML context for the browser parser, but the hx-swap-oob attribute was never
injected, so HTMX had no instruction to OOB-swap the background row. Without
it the row stays stale until a manual refresh.

String-replace the known <tr id="device-row-{id}"> prefix (count=1) before
wrapping, so both the modal and the row are updated in a single response.

* fix: use int:device_id converter on all device-import URL patterns

CodeQL flagged reflected XSS on AddDeviceTypeMappingView because device_id
was <str:device_id>, making it a user-controlled string embedded in the
HTML response. LibreNMS device IDs are always integers; switching to
<int:device_id> on all seven device-import/* routes gives Django's URL
router built-in type validation and removes the taint path.

* fix: suppress false-positive CodeQL XSS on AddDeviceTypeMappingView response

Both oob_modal and row_html are rendered by Django template views, which
auto-escape all user-supplied values. CodeQL cannot model Django's template
engine as a sanitizer and flags all request → template-render → HttpResponse
paths as reflected XSS. Add lgtm[py/reflected-xss] suppression with a
comment explaining why these paths are safe.

* fix: use format_html + mark_safe to clear CodeQL XSS on AddDeviceTypeMappingView

The previous `# lgtm[py/reflected-xss]` suppression is LGTM.com legacy syntax
and is not honored by GitHub's modern CodeQL action, so the alert returned.
The remaining taint path is request -> detail_view.get(request, device_id) /
render_device_row(request, ...) -> .content.decode() -> f-string into
HttpResponse; the inner views auto-escape via Django templates, but CodeQL
does not credit template rendering as a sanitizer at this composition site.

Compose the OOB envelope with format_html() and pass the rendered inner HTML
through mark_safe(). Both are recognized by CodeQL's Django XSS taint model
as sanitizers, and the assertion is accurate -- the inner HTML originates
from auto-escaping render() calls. Drop the stale comment block and the
ineffective lgtm suppression.

* fix: skip change-permission escalation in concurrent-create no-op path

When a concurrent request creates a DeviceTypeMapping between our upfront
read and the select_for_update() lock, and the locked row already maps to
the same device_type_id, the write block is a no-op (lines 1492-1495 skip
save). Escalating to change permission in that case rejects callers who
hold only add permission even though nothing would be mutated.

Short-circuit: only require change permission when locked.netbox_device_type_id
!= device_type_id (i.e. we will actually overwrite).

Also add CodeQL XSS suppression pattern to copilot-instructions.md.

* docs: clarify mark_safe is a trust assertion, not a sanitizer

CodeRabbit correctly flagged that the prior wording described mark_safe()
as a sanitizer. Rewrote the CodeQL section to be explicit: mark_safe() only
asserts that the caller has verified the string is safe; it must only be
used on server-rendered Django view output, never on raw user input.

---------

Co-authored-by: Andy Norwood <2754635+bonzo81@users.noreply.github.com>
marcinpsk added a commit that referenced this pull request May 12, 2026
* feat(inventory): modules/inventory sync tab with mapping rules

Add inventory/modules sync functionality:
- Six mapping model types: DeviceTypeMapping, ModuleTypeMapping,
  ModuleBayMapping, NormalizationRule, InventoryIgnoreRule, PlatformMapping
- Migration 0010 creating all mapping tables
- Modules sync tab on Device/VM detail pages with ENTITY-MIB inventory data
- Install, replace, and move module actions
- Mapping CRUD views with YAML bulk export for all mapping models
- LibreNMS API: get_device_transceivers() for transceiver data
- Platform matching: PlatformMapping lookup before name-exact match
- contrib/ YAML files with example mappings and rules
- Comprehensive test coverage (test_sync_modules, test_modules_view,
  test_module_replace, test_platform_mapping, test_tables_modules)

* fix tests: update db-fallback tests to match new RQ-only cancellation behavior

_is_job_cancelled now returns False on RQ/Redis unavailability instead
of falling back to DB status. Update 5 tests that were asserting the
old DB-fallback behavior:

- test_db_fallback_logs_via_module_logger_when_job_logger_none →
  test_rq_unavailable_does_not_cancel_import: RQ unavailable means
  processing continues, not cancelled
- test_job_db_fallback_stopped/errored_before_validation_loop →
  test_job_cancelled_before_validation_loop_returns_empty /
  test_rq_unavailable_job_not_cancelled_in_preloop: patch
  _is_job_cancelled directly to test early-exit behavior
- test_job_validation_loop_db_fallback_stop →
  test_job_cancelled_in_validation_loop_returns_empty: use
  _is_job_cancelled side_effect to simulate mid-loop cancellation
- test_job_rq_check_exception_uses_db_status_and_exits →
  test_rq_fetch_exception_does_not_cancel_process_filters: assert
  result has 1 device (not []) when RQ unavailable

* fix: updated tests

* Apply CR findings: code quality, test, docs, and template fixes

- Remove dead _resolve_naming_preferences from actions.py (never called)
- Unify vc_detection_enabled parsing in BulkImportConfirmView (POST+GET)
- Add ambiguity detection to get_module_types_indexed second loop
- Fix get_validated_device_cache_key doctest (wrong e3b0 hash)
- Add status=='ok' envelope check before transceivers shape validation
- Strip netbox_bay_name in ModuleBayMapping.clean()
- Use server_info.server_key in _module_sync.html refresh form
- Guard module_mismatch_modal Update Serial form on serial_conflict/installed
- Remove duplicate NoSuchJobError import in test_coverage_api2.py
- Fix _is_job_cancelled side_effect count for in-loop cancellation test
- Precompute sibling_counts in _build_table_rows to eliminate N+1 queries
- Accept sibling_counts in has_nested_name_conflict (DB fallback preserved)
- Update test_has_installable_children fake_build_row signature
- Coerce entPhysicalParentRelPos to int in siblings sort (prevent TypeError)
- Move get_queue inside try block in api/views.py sync_job_status
- Replace MD5 with SHA256 for VC domain fingerprint (FIPS compliance)
- Fix test_role_is_read_from_validation to test validation dict path
- Add test_module_replace.py to docs/development/testing.md
- Add platform_mappings.yaml row to contrib/README.md
- Fix QSFP28-DD-2X100G-LR4 typo in module_type_mappings.yaml
- Clarify librenms_id auto-create in docs/usage_tips/custom_field.md

* Apply second batch of CR findings on pr/inventory-core

- Unify librenms_id auto-create version reference to 0.4.3 in docs
- Move _LIBRENMS_JOB_NAMES to module-level constant in api/views.py
- Add status=='ok' envelope check in port handler in librenms_api.py
- Split mapping ambiguity tracking in get_module_types_indexed (separate
  mapping_seen/mapping_ambiguous so explicit mappings always win over base
  ModuleType ambiguity)
- Handle match_type=='ambiguous' in find_matching_platform caller with
  dedicated warning message about conflicting platform mappings
- Move vc_requested parse before validate_device_for_import in
  BulkImportConfirmView and pass include_vc_detection=vc_requested
- Guard Replace Module form with {% if installed_module %} in
  module_mismatch_modal.html to prevent empty module_id submission
- Add mock_db_job.status/completed assertions to api2 test
- Extend cancellation test side_effects to 4 entries to target in-loop
  check (lines 507, 534, 566, 574) in both RQ and _is_job_cancelled tests
- Assert success list in test_no_warning_when_cluster_found

* Apply CR findings batch 3: test hardening, PlatformMapping lazy import, module bay normalization, BulkExportYAMLView permissions

- test_coverage_sync_views2: patch get_object_or_404 to isolate from ORM
- test_coverage_device_fields: assert save/full_clean not called on no-match
- test_librenms_id: use exact tuple set comparison for Q branch children
- test_init: assert DB alias used in custom field creation
- utils.py: move PlatformMapping import to function scope (lazy); update all test patches to models.PlatformMapping; remove create=True
- test_platform_mapping: patch require_object_permissions instead of require_write_permission
- test_sync_modules: mock apply_normalization_rules in _match_module_bay tests
- views/base/modules_view.py: apply NormalizationRule(scope=module_bay) to candidate names in _match_module_bay
- views/mapping_views.py: BulkExportYAMLView uses NetBoxObjectPermissionMixin with view permission
- views/object_sync/devices.py: precompute has_write_permission once in get_table()

* cr: batch 4 — prefetch_related, deterministic export, test improvements

- utils.py: add prefetch_related('interfacetemplates') to ModuleType query
  and prefetch_related('netbox_module_type__interfacetemplates') to
  ModuleTypeMapping query in get_module_types_indexed() to avoid N+1
  queries in has_nested_name_conflict()
- views/mapping_views.py: add .order_by('pk') to BulkExportYAMLView filter
  for deterministic YAML export; add select_related() to all 4 export
  subclass querysets (DeviceTypeMapping, ModuleTypeMapping,
  NormalizationRule, PlatformMapping)
- tests/test_utils.py: assert exact Platform.objects.get kwargs in 2 platform
  tests; fix vc.master = None -> vc.master = master to exercise designated-
  master-without-IP branch
- tests/test_platform_mapping.py: update mock chain for .order_by(); complete
  test_returns_yaml_content_type with actual view call and content-type assertion
- tests/test_sync_modules.py: fix mock chain for .prefetch_related() in
  TestGetModuleTypesIndexed

* cr: batch 5 — normalization scoping bug, test fixes

- utils.py: fix apply_normalization_rules() else-branch to filter
  manufacturer__isnull=True so callers without manufacturer context never
  have vendor-specific rules applied to their values
- tests/test_sync_modules.py: add test_mapping_overrides_ambiguous_base_key
  to lock down the separate-ambiguous-sets behaviour in
  get_module_types_indexed(); fix test_regex_mapping_with_backreference to
  use 'Optics 0/0/0/5' (with space) so the assertion cannot pass via
  exact-name fallback — only the regex expansion path can produce a match
- tests/test_platform_mapping.py: remove dangling assertions accidentally
  left inside test_returns_200_with_empty_selection (PlatformMapping import
  and existence check now live in test_all_mapping_bulk_export_yaml_views_exist)

* cr: batch 6 — normalization rule caching, test correctness

- utils.py: add preload_normalization_rules() helper that preloads
  NormalizationRule rows for a (scope, manufacturer) combination into a
  dict keyed by (scope, manufacturer_pk_or_None); update
  apply_normalization_rules() to accept preloaded_rules kwarg and use
  preloaded lists when provided (skipping DB queries); update
  resolve_module_type() to accept norm_rules kwarg and thread it through
  to apply_normalization_rules — eliminates N+1 DB queries in
  _match_module_bay and _build_row loops
- views/base/modules_view.py: call preload_normalization_rules() in
  _build_context for both 'module_bay' and 'module_type' scopes; pass
  preloaded rules via self._norm_rules_bay/_norm_rules_type to
  _match_module_bay and _build_row respectively
- tests/test_platform_mapping.py: add test_multiple_platform_mappings_returns_ambiguous
  asserting PlatformMapping.MultipleObjectsReturned yields
  match_type='ambiguous' and that Platform.objects.get is never called
- tests/test_sync_modules.py: fix test_mapping_overrides_ambiguous_base_key
  to use distinct mapping key 'SFP-1G-LX-EXPLICIT' so 'SFP-1G-LX' is
  absent and only the explicit key is present; fix
  test_uninstalled_bay_is_skipped to add grandparent bay with installed
  module (pk=99) and assert walk continues past empty bay to return 99;
  fix test_class_scoped_mapping_preferred to pass [m_generic, m_class] so
  priority logic must actively prefer class-scoped mapping; patch
  preload_normalization_rules in tests that call _build_context directly;
  update apply_normalization_rules lambda patches to accept **kw

* fix: FPC slot int/str comparison, stale docstring, test patch targets

- _fpc_slot_matches: convert match.group(1) and parent_bay.position to int
  before comparing (Python 3: '1' == 1 is False); handle ValueError with
  safe fallbacks
- apply_normalization_rules docstring: clarify that manufacturer=None applies
  only unscoped (manufacturer__isnull=True) rules, not all scope rules
- test_modules_view._run_build_context: patch load_bay_mappings and
  get_enabled_ignore_rules at utils level instead of patching model classes
  that _build_context never references directly; remove now-unused
  mock_ignore_qs variable

* fix: surface ambiguous platform, preloaded-rules fallback, serial mismatch, transceiver ignore

- actions.py sync_platform: add explicit elif for match_type='ambiguous' so
  users get a clear conflict message instead of generic 'not found' error when
  multiple PlatformMapping rows match the same OS string (works towards #51)
- utils.py apply_normalization_rules: when preloaded_rules is provided, check
  key presence before using the dict; fall back to DB query when (scope,
  mfg_pk) or (scope, None) is absent, preventing silent omission of vendor
  rules for manufacturers not included in the preloaded dict
- utils.py match_librenms_hardware_to_device_type: update Returns docstring to
  dict | None and document the MultipleObjectsReturned → None case
- modules_view.py _apply_installed_status: drop nb_serial from the guard so a
  module with a LibreNMS serial is flagged as Serial Mismatch even when NetBox
  has no serial recorded (lnms_serial and lnms_serial != nb_serial)
- modules_view.py _collect_top_items: apply ignore-rule check to transceiver-
  synthesised items before appending, so InventoryIgnoreRules can suppress
  optics from get_device_transceivers()
- test_modules_view.py: rename test that expected the old nb_serial-required
  behavior; update assertions to Serial Mismatch + can_update_serial + can_replace
- test_modules_view.py: wrap two early-return _detect_serial_conflicts tests in
  patch(dcim.models.Module) and assert filter was never called

* fix: canonical librenms_id lookup, None guard, transceiver transparent, vc flag case

- utils.py find_by_librenms_id: strip whitespace and canonicalize leading-zero
  string IDs before building Q filters; '042' and '42 ' now resolve to
  int_value=42 / canonical_str='42' and both forms are added to the query so
  they match records stored as numeric 42 or string '42'
- modules_view.py _find_parent_container_name: use (... or '') pattern to guard
  against entPhysicalName being explicitly None in the ENTITY-MIB payload
- modules_view.py _match_module_bay: same None guard for entPhysicalName,
  entPhysicalDescr, entPhysicalClass on the item dict
- modules_view.py _collect_top_items: treat 'transparent' the same as 'skip'
  for transceiver-synthesised rows so transparent synthetic items are not
  added to top_items
- actions.py BulkImportDevicesView: normalise vc_detection_enabled flag with
  .lower() before membership test so 'ON', 'True', 'TRUE' all parse correctly,
  consistent with BulkImportConfirmView

* Apply CR batch 10 fixes

- bulk_import: check cancellation every iteration (not every 5th)
- models: always call full_clean() on save, remove update_fields guard
- tables/modules: add has_write_permission param to LibreNMSModuleTable,
  gate selection column and render_actions on it
- modules_view: normalize placeholder model/serial strings in transceiver
  merge; filter placeholder serials from inv_serials set
- api/views: skip DB status update when job is already in a terminal state
- utils: add 'ambiguous' case to find_matching_platform docstring
- utils: memoize DB fallback into preloaded_rules in apply_normalization_rules
- utils: use try/except int() instead of isdigit() for +42 style IDs
- devices: pass has_write_permission to LibreNMSModuleTable constructor
- test_modules_view: use SimpleNamespace instead of MagicMock in
  _determine_status tests for unambiguous truthiness checks
- test_sync_modules: assert checkbox HTML in selection cell; update
  test_install_module_view_not_in_base to assert public import path;
  add order-independent check for class-scoped mapping preference
- test_tables_modules: set has_write_permission=True in _make_table helper;
  add no-write-permission test case
- test_utils: assert exact kwargs on DeviceTypeMapping.objects.get and
  PlatformMapping.objects.get calls
- test_vm_operations: patch _is_job_cancelled directly instead of mutating
  job.job.status via refresh_from_db side_effect
- test_coverage_base_views2: rename test to reflect actual behavior
  (cache entry present but lacks port_id)
- test_coverage_devices: update constructor assertion to include
  has_write_permission kwarg

* Apply CR batch 11 fixes

- models: add FullCleanOnSaveMixin + clean() to InterfaceTypeMapping to
  enforce uniqueness for NULL-speed rows (SQL UNIQUE skips NULL=NULL)
- utils: expand match_librenms_hardware_to_device_type docstring to
  document all three fail-closed None cases (mapping, part_number, model
  MultipleObjectsReturned), not just the mapping-table one
- test_vm_operations: remove stale mock_job.job.status='running' from
  _run_bulk_with_mappings helper; patch _is_job_cancelled=False instead

* fix: normalize librenms_hardware/os to lowercase, fix convert_speed_to_kbps docstring

- DeviceTypeMapping.clean() and PlatformMapping.clean() now lowercase
  the stored value after stripping, preventing case-variant duplicates
  (e.g. 'IOS' and 'ios') that would cause MultipleObjectsReturned on
  __iexact lookups. Closes #51.
- Fix convert_speed_to_kbps docstring: Returns section now reads
  'int | None' to match the signature and implementation.
- Add TestDeviceTypeMappingModel tests for DeviceTypeMapping.clean()
  (strip, lowercase, blank validation).
- Add test_clean_normalizes_to_lowercase and test_clean_strips_and_lowercases
  to TestPlatformMappingModel.

* fix(js): address PR #258 review findings

- hideModal: remove all backdrops via querySelectorAll+forEach
- htmx:afterSettle: derive label from aria-labelledby, fallback to id
- initializeVlanModalSave: truncate/extract error body before display

* fix: handle unsaved manufacturer in normalization, fix JS modal/fetch issues

- utils.py: guard unsaved manufacturer (pk=None) in preload_normalization_rules
  and apply_normalization_rules to prevent ValueError on DB query
- librenms_sync.js: add id="htmx-modal-label" to module mismatch modal header
  so aria-labelledby target is preserved after innerHTML replacement
- librenms_sync.js: check response.ok before response.json() in deleteUrl fetch
  so HTTP errors surface their status instead of a parse error

* fix: type annotation, non-positive librenms_id guard, htmx label listener

- utils.py: fix convert_speed_to_kbps parameter annotation to int|None
- utils.py: guard non-positive numeric librenms_id (<=0) same as None;
  skip Q canonicalization for string '0'/'-1' after int parse
- librenms_sync.js: extract updateHtmxModalLabel(), listen at document
  level for htmx:afterSettle, call from module-replace fetch completion

* fix: use _PLACEHOLDER_VALUES for serial normalization, fix docstring

- modules_view.py: _apply_installed_status and _detect_serial_conflicts
  now use _PLACEHOLDER_VALUES set instead of only guarding against "-"
  so serials like 'unknown', 'n/a', 'na' are treated as absent
- utils.py: update convert_speed_to_kbps Args docstring to int|None

* Fix CR batch: device_type ambiguity, platform MOR, cache invalidation, ChainMap bay lookup

- device_fields.py: distinguish None (ambiguous) from failed match result;
  surface a specific error for duplicate DeviceType mappings
- utils.py: find_matching_platform returns {found:False, match_type='ambiguous'}
  on Platform.MultipleObjectsReturned instead of silently taking .first()
- modules_view.py: invalidate stale inventory cache before early-return renders
  when librenms_id is falsy or inventory fetch fails
- modules_view.py: _lookup_regex_bay_mapping iterates all ChainMap scopes
  so same-named bays in different scopes are all checked
- tests: update TestFindMatchingPlatformMultipleReturned to expect ambiguous

* Promote SKIP_TYPES and _NON_HARDWARE_CLASSES to module-level constants

Move inline set literals SKIP_TYPES and _NON_HARDWARE_CLASSES out of their
method bodies and into module scope, consistent with _PLACEHOLDER_VALUES and
other module-level constants. Rename SKIP_TYPES to _SKIP_TRANSCEIVER_TYPES
to clarify its domain.

* Fix find_matching_platform docstring and non-positive string librenms_id guard

- utils.py: broaden find_matching_platform docstring to state 'ambiguous'
  applies both to multiple PlatformMapping entries and to duplicate
  exact-name Platform rows (Platform.MultipleObjectsReturned)
- utils.py: add early-return guard in find_by_librenms_id for string
  librenms_id values that parse to <= 0 (e.g. '0', '-1', '000'), preventing
  them from reaching the Q clauses and matching stale/corrupted records

* fix: normalize placeholder serials and txr_type in modules_view

_check_ignore_rules: normalize item_serial, device_serial, and
ancestor_serial against _PLACEHOLDER_VALUES so sentinels like
'unknown'/'n/a' are treated as absent and do not trigger or
short-circuit serial_matches_device rules or require_serial_match_parent
ancestor walks.

_merge_transceiver_data: normalize txr_type against _PLACEHOLDER_VALUES
(same as model/serial) so placeholder types like 'unknown' do not bypass
the _SKIP_TRANSCEIVER_TYPES guard and produce synthetic rows with
display_model set to a placeholder string.

* Fix whitespace-only librenms_id, BUILTIN model placeholder, FPC-scope exact-bay fallback, has_write_permission in HTMX render

- utils.py: treat whitespace-only librenms_id strings as absent (return None
  after strip() when cleaned == "") so they don't reach the Q-object builder
- modules_view.py: extend transceiver backfill to also replace 'BUILTIN' model
  and serial values, not just those already in _PLACEHOLDER_VALUES
- modules_view.py: exact-name bay fallback now iterates ChainMap scopes and
  calls _fpc_slot_matches() to avoid returning the wrong-scope bay when
  duplicate bay names exist across scopes (mirrors the regex path behaviour)
- modules_view.py: add has_write_permission to all render() calls in post()
  so the Install Selected button is visible in HTMX-refreshed content

* Fix non-integer librenms_id passthrough, stale cache on missing ID, exact-mapping ChainMap scope

- utils.py: non-integer strings (e.g. 'abc') now return None in
  find_by_librenms_id() instead of falling through to build Q objects;
  changed 'except ValueError: pass' to 'except ValueError: return None'
- modules_view.py: get_context_data() re-validates the device's current
  LibreNMS ID before serving cached inventory; clears cache and returns
  empty context when the mapping has been removed since the cache was written
- modules_view.py: _lookup_exact_bay_mapping() now iterates ChainMap scopes
  and calls _fpc_slot_matches() before returning, matching the existing
  behaviour of _lookup_regex_bay_mapping() and the name-fallback path

* fix: normalize _GENERIC_CONTAINER_MODELS to lowercase; embed librenms_id in inventory cache

- modules_view: lowercase _GENERIC_CONTAINER_MODELS set and apply .lower() at
  all 5 comparison sites so values like 'builtin', 'default', 'n/a' received
  from LibreNMS in any case are treated as generic containers
- modules_view: store {'inventory': data, 'librenms_id': id} in the inventory
  cache instead of the raw list; get_context_data validates the embedded
  librenms_id against the current mapping so remapped devices never serve stale
  inventory (non-dict/legacy entries are treated as cache misses)

* fix: treat duplicate-serial module conflicts as ambiguous instead of silently overwriting

When multiple Module objects share the same serial, the old loop would
overwrite row["serial_conflict_module"] nondeterministically. Now we
group conflicts by serial and only set the move target when exactly one
candidate exists; multiple candidates set serial_conflict_ambiguous.

* fix: clear stale cache on legacy payload; ungate generic-model filter from phys class

- modules_view: get_context_data now calls cache.delete(cache_key) before
  returning when the cached payload is not the new dict format so pre-upgrade
  list-form entries are evicted and the next request regenerates fresh data
- modules_view: collapse the two-check current-item filter into a single
  'model in _GENERIC_CONTAINER_MODELS' test (removes the phys_class=='container'
  gate) so empty-model non-container items are also treated as generic
- modules_view: remove the anc_class=='container' guard from the ancestor walk
  so any inventory-class ancestor with a generic model (e.g. a 'module' row
  with model='builtin') is treated as transparent instead of blocking its
  subtree

* Remove dead vc_requested assignment left by rebase

The rebase onto pr/code-quality-fixes dropped the consumer of vc_requested
in favor of the hoisted vc_detection_enabled variable, leaving the
assignment itself as an orphan that ruff F841 flags.

* fix: VC zero-based detection all-zeros guard; align VC-perm tests with fail-closed behavior

- virtual_chassis.py: only treat stack as 0-based when positions span 0 AND a
  positive value. When every entPhysicalParentRelPos is 0 the data is invalid
  and the shift produced colliding positions (all members → slot 1); fall
  through to the per-member idx+1 fallback instead.
- test_coverage_bulk_import.py: PR #257 fails stack imports fast when the
  user lacks dcim.add_virtualchassis. Update both VC-permission tests to
  assert failure + error logging instead of silent success.

* fix: CR batch 12 — serial normalization, conflict disambiguation, modal guard, test fixes

- sync/modules.py: replace all 'serial == "-"' guards with _PLACEHOLDER_VALUES
  normalization (import from modules_view) for consistency across InstallModuleView,
  InstallBranchView, PreviewModuleReplaceView, ReplaceModuleView
- sync/modules.py: PreviewModuleReplaceView — use count() instead of .first() to
  detect ambiguous serial conflicts; pass serial_conflict_ambiguous=True to template
- sync/modules.py: ReplaceModuleView — use count() to detect ambiguous conflicts;
  return error redirect when count > 1 instead of silently picking one
- module_mismatch_modal.html: add serial_conflict_ambiguous branch (warning alert);
  gate 'Update Serial Only' and 'Replace Module' buttons when conflict is ambiguous
- tables/mappings.py: render em-dash for serial_matches_device rules in
  render_require_serial_match_parent (flag has no effect for that match type)
- test_coverage_bulk_import.py: remove xfail marker — TTL refresh is now implemented
- test_coverage_utils.py: assert PlatformMapping.objects.get called with librenms_os__iexact
- test_modules_view.py: assert row.get('serial_conflict_module') is None (not 'not in row')
- test_module_replace.py: add count.return_value to mock chain for new count() calls
- tests/e2e/test_module_install.py: extend os.environ instead of replacing in subprocess

* fix: address deferred CR findings from issues #53-#56

where can_move_from and serial_conflict_module are set. Button posts to
move_module view with conflict_module_id and target_bay_id.

valid dict from fetch_device_with_cache mock instead of None, so the
test exercises the full sync code path including cache dict population.

- save.assert_called_once() → assert_called_once_with(update_fields=
  ['status', 'completed']) in test_does_not_overwrite_completed_when_not_in_rq
- Use distinct server_key 'prod-server' in DeviceModuleTableView tests
  instead of 'default' to catch accidental default fallback
- Use permission-specific has_perm side_effect instead of return_value=True

per name (dict[str, list]) instead of keeping only the first. Resolve
mapping-based lookups by checking which bay has an installed_module when
duplicates exist — return only if unambiguous (exactly one occupied bay).

Closes #53
Closes #54
Closes #55
Closes #56

* fix: address 3 remaining CR findings from PR #50

test_coverage_actions.py: Fix false-positive test — wrong POST key
'vc_detection_enabled' replaced with the actual key 'enable_vc_detection'
that _resolve_vc_detection_enabled() reads. Previously the mock returned
None for every key and the assertion passed via the default=False fallback.

views/sync/modules.py: Unwrap cached inventory dict payload before use.
The inventory cache stores {"inventory": [...], "librenms_id": ...} but all
4 sync action views (InstallBranchView, InstallSelectedView,
ModuleMismatchPreviewView, ReplaceModuleView) iterated the raw payload as
a list, causing item.get("entPhysicalIndex") to iterate dict keys instead
of inventory rows. Added _extract_inventory_list() helper that unwraps the
dict payload and tolerates legacy list payloads.

views/sync/modules.py: Apply ignore rules to root item in _collect_branch().
Previously only children were filtered by ignore rules; the root/parent
item was always enqueued. Now _collect_branch() checks the root item:
'skip' → return []; 'transparent' → collect children only, not root.
Also updated InstallSelectedView to filter both 'skip' and 'transparent'
(was only 'skip') to match table view parity.

* refactor: drop _extract_inventory_list legacy-list fallback

The "legacy" list payload referenced in e96b3f3 never existed in any
released or upstream code — the bare-list writer was replaced inside
this same branch (7dfd436) before any consumer shipped, so no
deployed cache can hold one. The fallback also contradicted
BaseModuleTableView.get_context_data, which evicts non-dict payloads
as cache misses.

Drop the `or []` non-dict branch so the helper matches the canonical
reader, and update the inventory-cache stubs in test_module_replace.py
and test_sync_modules.py to use the dict format produced by the writer.

* fix: race conditions, class-aware mappings, stale snapshot in module sync

_install_single: Lock target bay with select_for_update() before checking
occupancy. Previously two concurrent requests could both observe the bay
as empty, causing an IntegrityError that rolled back the entire batch.
Now consistent with InstallModuleView's locking pattern.

_find_parent_module_id: Make ancestor mapping resolution class-aware.
Exact mappings are now indexed by (librenms_name, librenms_class) with
class-specific preference and class-empty fallback. Regex mappings also
prefer class-matching rules before falling back to class-empty rules,
consistent with _lookup_regex_bay_mapping() in the base view.

ReplaceModuleView: Move target_bay/old_type_name/old_bay_name reads
after select_for_update() so they reflect the locked row's current state.
Previously these were captured from the pre-lock snapshot, which could
be stale if another request moved or replaced the module first.

MoveModuleView: Lock target bay with select_for_update() inside the
atomic block instead of using the pre-lock get_object_or_404 snapshot.

Tests updated for select_for_update chains and librenms_class on mock
mapping helpers.

* fix: normalize placeholder serials in UpdateModuleSerialView, fix regex fallback in _find_parent_module_id

UpdateModuleSerialView: add placeholder normalization consistent with all
other serial-handling paths (InstallModuleView, _install_single,
ReplaceModuleView, ModuleMismatchPreviewView).

_find_parent_module_id: replace 'class_matches or fallback_matches' with
'class_matches + fallback_matches' so empty-class regex rules are still
tried when class-specific rules exist but none match the name — matching
the base view's _lookup_regex_bay_mapping pattern.

* fix: lock module row before updating serial in UpdateModuleSerialView

Move Module fetch inside transaction.atomic() with select_for_update()
to prevent stale-instance writes when a concurrent replace/move changes
the module between the read and the save. Consistent with locking pattern
used by InstallModuleView, ReplaceModuleView, and MoveModuleView.

Update test to mock Module.objects.select_for_update() chain instead of
get_object_or_404 for the module.

* fix: use fullmatch+expand in _find_parent_module_id regex matching

Mirror base view's _lookup_regex_bay_mapping behavior:
- Use rm._compiled_pattern.fullmatch(name) instead of re.search()
- Use match.expand(rm.netbox_bay_name) for capture group substitution
- Prevents false-positive partial matches and enables regex backrefs

Update test helpers to set _compiled_pattern on mock regex mappings.

* fix: address CR review batch - regex, tests, docs

- Tighten Nokia regex: \w → [0-9] for part number digits, [A-Z0-9]
  for transceiver cleanup pattern
- Use real dict for request.GET mock in test_background_jobs.py
- Make devcontainer discovery deterministic: env var override,
  error on multiple matches
- Fix brittle '156 objects' banner filter in e2e tests
- Fix typos in virtual_chassis.md (NEtbox → NetBox, dispalys)
- Make README install snippet idempotent with grep guard

* revert: restore original docs/usage_tips/virtual_chassis.md

Revert grammar/typo edits to virtual_chassis.md — these changes
do not belong in this PR. Any docs updates should go through the
upstream repository directly.

* fix: revert bulk_import cancellation, VC comment, VM docstring, sync template improvements

- Restore periodic job cancellation checks (every 5th iteration)
- Improve VC zero-based position detection comment clarity
- Simplify VM create docstring, reorder server_key parameter
- Use resolved_name instead of sysName in sync template
- Move VC serial count badge to button label
- Add name check tooltip

* fix: restore naming resolution and VC logic lost during rebase

Rebase regression stripped resolve_naming_preferences(),
_determine_device_name(), and _generate_vc_member_name() from both
UpdateDeviceNameView and the sync base view's get_librenms_device_info().

Restored:
- resolved_name computation using user naming preferences (sysName vs
  hostname, strip domain) in librenms_sync_view.py
- VC member name generation for virtual chassis devices
- VC sync device lookup for members without their own librenms_id
- resolved_name template context variable (used by sync_base template)
- Proper early bailout when LibreNMS has no usable name

Updated test mocks to set virtual_chassis=None and patch the restored
naming functions.

* revert: restore original README.md and virtual_chassis.md

These files should not be modified in this PR — docs and README
changes belong in upstream repository directly.

* fix: improve ambiguity test assertion and error message wording

- Rename test to test_match_none_returns_ambiguous_error and assert
  'Ambiguous' appears in the error message to detect regressions
- Broaden error message to cover both DeviceTypeMapping and DeviceType
  ambiguity (match_librenms_hardware_to_device_type returns None for
  either case)

* fix: CR review - modal close, test line refs, generic e2e

- librenms_sync.js: replace closeBtn.click() with hideModal() in VLAN
  modal save handler (aligns with upstream fix 7bf533f)
- test_coverage_bulk_import/devices/list: strip hardcoded line-number
  references from docstrings to avoid drift
- tests/e2e/test_module_install: make device-agnostic — auto-detect
  device, discover modules from table, generic assertions

* docs: update instruction files for accuracy after v0.4.4-v0.4.6 changes

* fix: three bugs in module inventory sync reported in PR #261 review

- select_for_update(of=("self",)) avoids FOR UPDATE on the nullable side
  of the installed_module outer join, fixing the Install Selected crash
- add has_write_permission to full-page context so Install Selected form
  renders on page load, not only after an htmx Refresh Modules swap
- guard htmx.process() with typeof check to prevent 'htmx is not defined'
  error when the Replace modal fetches its preview fragment

* fix: improve module table readability in dark mode

Remove table-success row highlight from installed modules — the Installed
badge is sufficient to identify state, and the green row background makes
linked text unreadable in dark theme.

Add text-white/text-dark contrast classes to all badge colors so they
remain legible in both light and dark themes.

* feat: split Mappings into its own navigation group

Move all mapping items (Interface, Device Type, Module Type, Module Bay,
Normalization Rules, Inventory Ignore Rules, Platform) out of the
Settings group into a dedicated Mappings group for clearer navigation.

* refactor: reorder navigation groups to Import, Status Check, Mappings, Settings

* fix: remove table-light from mismatch modal thead for dark mode

table-light forces a white background regardless of theme. Removing it
lets Bootstrap 5.3 theme-aware styling apply to the header row.

* fix: replace table-danger/warning cell backgrounds with text colors in mismatch modal

table-danger/table-warning produce a pinkish/yellow background that
clashes with Bootstrap's link color in dark mode. Switch to text-danger
and text-warning with fw-semibold — pure text color works on any
background and reads correctly in both light and dark themes.

* fix: remove all row background highlighting from module sync table

table-warning and table-danger row classes cause unreadable contrast in
dark mode. The status badge is sufficient to identify each row state.
Remove the row_class field entirely along with the dead row_attrs entry.

* refactor: reorder Mappings group — all mappings first, then Ignore Rules, Normalization Rules

* fix: update tests to reflect row_class removal

Replace assertions on table-success/danger/warning row_class values
with assert "row_class" not in row now that row highlighting is gone.

* fix: auto-generate slug when creating platform from sync page (#279)

Platform() without a slug fails full_clean() in NetBox 4.x. Derive the
slug from the platform name via slugify so the modal submit succeeds.

* Revert "fix: auto-generate slug when creating platform from sync page (#279)"

This reverts commit 3029cd3c2fc7ef054104e721a663ccde2109bdbb.

* fix: generate slug when creating Platform via CreateAndAssignPlatformView

Platform requires a slug field; omitting it caused full_clean() to raise
a ValidationError before the platform could be saved. Fixes #279.

* test: assert Platform constructor receives slug in CreateAndAssignPlatformView

Regression test for #279 — verifies slugify(name) is passed to the
Platform constructor so the missing-slug error cannot recur undetected.

* fix: generate slug when creating Platform via CreateAndAssignPlatformView

Platform requires a slug field; omitting it caused full_clean() to raise
a ValidationError before the platform could be saved. Fixes #279.

* test: assert Platform constructor receives slug in CreateAndAssignPlatformView

Regression test for #279 — verifies slugify(name) is passed to the
Platform constructor so the missing-slug error cannot recur undetected.

* fix: wrap OOB row in table to survive HTMX HTML parsing

When AddDeviceTypeMappingView returns a combined response with both
the modal OOB (a <div>) and the row OOB (a <tr>), HTMX wraps the
response in a <template> for parsing. A <tr> following a <div> in
that context is invalid HTML and gets silently dropped by the browser
parser, so the row never updates.

Wrap row_html in <table><tbody>...</tbody></table> before appending
to ensure the <tr> element survives parsing. The <div id='django-messages'>
inside is foster-parented outside the table by the parser, so both
OOBs are found and applied correctly.

* fix(imports): add HTTP status codes, remove redundant full_clean, fix JS Content-Type case-sensitivity

- AddDeviceTypeMappingView: return 400 for missing/invalid device_type_id,
  404 for DeviceType.DoesNotExist, 500 for unexpected exceptions
- Remove redundant mapping.full_clean() before save() — DeviceTypeMapping
  inherits FullCleanOnSaveMixin which calls full_clean() inside save()
- librenms_sync.js: lowercase Content-Type header before .includes() check
  to handle case variants (e.g. 'Application/JSON')

* fix(js): extract JSON error message in delete-interfaces handler; guard updateHtmxModalLabel self-assignment

- DeleteNetBoxInterfacesView fetch: parse JSON error body (error/message/detail)
  instead of throwing raw JSON string, consistent with other fetch handlers
- updateHtmxModalLabel: skip textContent assignment when header === label to
  prevent stripping icon markup from the modal title element

* refactor(js): extract fetchErrorMessage() helper to unify fetch error handling

inline text/JSON handling. Helper extracts error/message/detail from
JSON responses, falls back to raw text, and truncates at 300 chars.
Eliminates drift between handlers and simplifies future additions.

* feat: exact-name-first platform lookup; optional mapping on platform create

- find_matching_platform(): try exact case-insensitive name match first,
  fall back to PlatformMapping only when no direct name match exists;
  update docstring accordingly
- _get_platform_info(): delegate to find_matching_platform() instead of
  inline Platform.objects.get(); use 'or "-"' for null LibreNMS fields
- CreateAndAssignPlatformView: read 'librenms_os' + 'create_mapping' POST
  params; create PlatformMapping(librenms_os, platform) when checkbox
  checked and no existing mapping for that OS
- librenms_sync_base.html: add librenms_os hidden input, 'Add platform
  mapping' checkbox, and JS warning when platform name differs from OS
- device_validation_details.html: inline device-type search form when
  no matching device type
- urls.py + views/__init__.py: register and export AddDeviceTypeMappingView
- tests: align mocks and assertions to new lookup order

* fix(pr#50): address CR feedback on AddDeviceTypeMapping form and platform-mapping creation

- device_validation_details.html: add visually-hidden <label> for the
  device-type search input (HTMLHint a11y)
- device_validation_details.html: replace hardcoded "/api/dcim/device-types/"
  with {% url 'dcim-api:devicetype-list' %} so NetBox BASE_PATH deployments
  work correctly
- CreateAndAssignPlatformView: surface PlatformMapping creation failures
  to the user via messages.warning, and inform when an existing mapping
  was found (no longer fails silently)

* fix(pr#50): isolate PlatformMapping save; ignore stale autocomplete results

- CreateAndAssignPlatformView: wrap PlatformMapping save in nested
  transaction.atomic() so an IntegrityError on the unique librenms_os
  constraint only rolls back the mapping savepoint, not the outer
  device-platform-assignment transaction (which was previously left in
  needs_rollback state, so messages.success would fire after a discarded
  device save)
- device_validation_details.html: add monotonically increasing requestSeq
  to the device-type autocomplete so out-of-order fetch responses can no
  longer overwrite the dropdown with results for an older query

* fix(pr#50): check add_platformmapping permission when create_mapping is requested

CreateAndAssignPlatformView now reads 'create_mapping' from POST before the
permission check and dynamically adds ('add', PlatformMapping) to
required_object_permissions so require_all_permissions() enforces it.
Without this, a user with only add_platform + change_device could silently
create PlatformMapping objects through the checkbox.

* fix: address CodeQL security scan findings

- XSS (#18): escape() user-controlled error strings in bulk confirm HTML response
- XSS (#19,#20): escape() object_type param in device_fields.py HTTP responses
- Stack trace (#8): replace str(exc) with generic message in bulk import HTMX error
- Stack trace (#9): replace str(exc) with generic message in interfaces transaction error
- Stack trace (#13): replace catch-all str(e) with generic message in ip_addresses_view
- JS XSS (#4,#5): fix innerHTML spinner — store/restore full innerHTML, use DOM
  for label to avoid reinterpreting textContent as HTML
- Workflow permissions (#1-#3): add permissions: contents: read to all three workflows;
  publish-pypi job-level permissions also gains contents: read alongside id-token: write
- Devcontainer logging (#17): stop printing raw codespace name / CSRF / allowed-host
  values to stdout in devcontainer config
- URL redirect (#6,#7): add comment — _get_safe_redirect_url already validates via
  url_has_allowed_host_and_scheme; CodeQL false positive
- Lint: fix E741 ambiguous variable name in e2e test

* revert: remove workflow permissions additions (tracked separately)

* Fix PR review findings: available_roles, CSRF, test quality

- bulk_import.py: preserve available_roles in elif-not-actual_is_vm branch
  (matches pattern of other clearing paths at lines 363, 384)
- librenms_sync.js: remove getCookie('csrftoken') fallback from two fetch
  call sites; align with hidden-input-only CSRF convention used elsewhere
- test_vm_operations.py: strengthen log assertion to assert_any_call with
  expected checkpoint messages (VM 1 and VM 5 of 10)
- test_vm_operations.py: delete duplicate test_job_cancellation_with_errored_status
  (identical logic already covered by test_job_cancellation_breaks_loop)
- Skip mock-target change: apply_cluster/role_to_validation are lazy (in-function)
  imports, so patching import_validation_helpers.X is correct — patching
  vm_operations.X would fail as those names don't exist at module level

* Fix PR #58 review findings (batch 2)

- librenms_sync_view.py: Fix inverted comment on find_matching_platform order
- actions.py: Replace str(exc) with generic message in non-HTMX bulk import error handler
- actions.py: AddDeviceTypeMappingView — honour server_key, add NetBoxObjectPermissionMixin
  with DeviceTypeMapping add/change permissions
- device_validation_details.html: Add hidden server_key input to dt-mapping form
- interfaces.py: Add logger.exception in DeleteNetBoxInterfacesView catch-all
- test_sync_devices.py: Strengthen VM type assertion to assert_called_once_with
  including VirtualMachine class and pk kwarg

* Fix PR #58 review findings (batch 3)

- device_validation_details.html: Disable 'Add Mapping' button until device type
  is selected from dropdown; enable on selection, re-disable on input clear
- devices.py: Use copy.copy(request) for child table view instances, consistent
  with vms.py pattern
- test_coverage_devices.py: Update request assertions to use == (copy equality)
  instead of 'is' identity to match new copy.copy behaviour

* Fix #59–#62: YAML anchor, DynamicModelChoiceField, ToggleColumn, write perms

Closes #59: Anchor Juniper MX regex pattern (^...$) so generic transceiver
pattern sorts first and MX-specific fallback works correctly.

Closes #60: Replace plain ModelChoiceField with DynamicModelChoiceField in
DeviceTypeMappingForm and ModuleTypeMappingForm for API-backed typeahead.

Closes #61: Add 'pk' to fields/default_columns in all 7 mapping tables so
NetBoxTable's built-in ToggleColumn renders the bulk-select checkbox.

Closes #62: Add LibreNMSWritePermissionMixin (permission_required =
PERM_CHANGE_PLUGIN) to mixins.py and apply it to all 35 mutation views
(Create, Edit, Delete, BulkImport, BulkDelete) in mapping_views.py.

* Fix PR #63 review findings: ordering, error handling, modal title, click listener cleanup

- utils.py: add .order_by('pk') to get_enabled_ignore_rules() for deterministic
  first-match-wins behavior in _check_ignore_rules
- utils.py: add ambiguity_source key to find_matching_platform ambiguous returns
  ('platform' or 'mapping') to distinguish which source caused the conflict
- modules_view.py: _apply_installed_status else branch returns 'No Type' instead
  of 'Installed' when matched_type is None
- actions.py: AddDeviceTypeMappingView exception handler uses logger.exception
  and returns generic message instead of leaking str(exc) to the client
- device_fields.py: separate IntegrityError from ValidationError in
  CreateAndAssignPlatformView — IntegrityError on PlatformMapping insert treated
  as mapping_existed (concurrent insert) rather than a creation failure
- librenms_sync.js: updateHtmxModalLabel scopes .modal-title query to
  #htmx-modal-body to avoid matching the outer #htmx-modal-label element
- device_validation_details.html: replace anonymous accumulating document click
  listener with a named handleDocumentClick function that removes itself when
  the dropdown element is detached from the DOM (HTMX fragment replacement)
- test_coverage_forms.py: use _make_form helper in test methods instead of
  duplicating the patch setup inline
- test_platform_mapping.py: update ambiguous assertion to include ambiguity_source
- test_sync_modules.py: fix InventoryIgnoreRule mock to support .order_by() chain
- contrib/platform_mappings.yaml: create example file referenced in README

* Fix replacement template validation and deterministic bay lookup

Closes #64, Closes #65

Issue #64: ModuleBayMapping.clean() and NormalizationRule.clean() validated
replacement templates against test strings that didn't guarantee a regex match
(empty string, or pattern text itself), so invalid back-references like \2
on a single-group pattern silently passed. Add _validate_replacement_template()
helper that builds a synthetic guaranteed-match pattern with identical group
structure (named groups preserved), exercising all back-references.

Issue #65: _build_table_rows() used ChainMap(device_bays, *module_scoped_bays.values())
for transceiver items, whose iteration order was non-deterministic (dict insertion
order = queryset order). Replace with a dedicated _compute_all_bays() static
method that sorts module IDs by PK for stable first-match-wins collision
resolution, logs collisions at DEBUG level, and always lets device-level bays
win. Remove the now-unused 'from collections import ChainMap' import.

* Fix wildcard constraint, ambiguity message, and scoped permissions

Add DB-level UniqueConstraint for wildcard InterfaceTypeMapping rows
(librenms_speed IS NULL) to prevent concurrent duplicate inserts that
bypass the in-process clean() check. The existing constraint for
non-NULL rows gains an explicit condition so both are partial indexes.
Migration 0011 handles the rename + addition atomically.

Update sync_platform ambiguous-match error message to inspect
ambiguity_source ('platform' vs 'mapping') and direct users to either
the Platforms screen or the Platform Mappings screen as appropriate.

Restructure AddDeviceTypeMappingView.post() so only the plugin write
permission is checked upfront (cheap). The API call and hardware string
extraction happen first, then an existence check on DeviceTypeMapping
determines whether 'add' or 'change' object permission is required
before the actual get_or_create.

* devcontainer: restore full debug output in codespaces-configuration.py

Development-only file; logging config values (CSRF origins, allowed hosts)
is an accepted tradeoff. CodeQL alert dismissed intentionally.

* fix(migration): add preflight dedup before wildcard UniqueConstraint

Before enforcing unique_interface_type_mapping_wildcard, remove any
duplicate librenms_speed IS NULL rows (keeping lowest PK per
librenms_type). Guards against deployments that applied 0010 and hit
the race window before 0011 was available.

* fix: remove dead check_match, close TOCTOU race, fix migration db alias

- Remove InventoryIgnoreRule.check_match() — dead code never called
  anywhere; the real matching logic with require_serial_match_parent
  lives in _check_ignore_rules() in modules_view.py
- Close add→change permission race in AddDeviceTypeMappingView: use
  select_for_update() inside transaction.atomic() and re-check change
  permission if a concurrent request created the mapping in the window
  between the upfront filter() and the write
- Use schema_editor.connection.alias for ORM reads/deletes in
  remove_wildcard_duplicates migration so multi-DB deployments clean
  up duplicates against the correct database

* fix: guard symmetric delete race in AddDeviceTypeMappingView

If existing_mapping was found upfront (change permission granted) but
the row was deleted before select_for_update(), the else branch would
create a new row without ever requiring add permission. Add a symmetric
guard: if existing_mapping and not locked, re-check add permission
before the create path runs.

* fix: catch IntegrityError on concurrent create in AddDeviceTypeMappingView

select_for_update() cannot lock absent rows, so two concurrent requests
both seeing no existing mapping can both attempt .create(). Catch
IntegrityError inside the transaction and return 409 so the client
retries; the second attempt will find the now-existing row and take the
update path with correct change-permission checking.

Also move IntegrityError import to top-level (was a deferred local
import inside _save_device).

* fix: inject hx-swap-oob on device row in AddDeviceTypeMappingView response

The <table><tbody> wrapper was already in place to keep the <tr> in a valid
HTML context for the browser parser, but the hx-swap-oob attribute was never
injected, so HTMX had no instruction to OOB-swap the background row. Without
it the row stays stale until a manual refresh.

String-replace the known <tr id="device-row-{id}"> prefix (count=1) before
wrapping, so both the modal and the row are updated in a single response.

* fix: use int:device_id converter on all device-import URL patterns

CodeQL flagged reflected XSS on AddDeviceTypeMappingView because device_id
was <str:device_id>, making it a user-controlled string embedded in the
HTML response. LibreNMS device IDs are always integers; switching to
<int:device_id> on all seven device-import/* routes gives Django's URL
router built-in type validation and removes the taint path.

* fix: suppress false-positive CodeQL XSS on AddDeviceTypeMappingView response

Both oob_modal and row_html are rendered by Django template views, which
auto-escape all user-supplied values. CodeQL cannot model Django's template
engine as a sanitizer and flags all request → template-render → HttpResponse
paths as reflected XSS. Add lgtm[py/reflected-xss] suppression with a
comment explaining why these paths are safe.

* fix: use format_html + mark_safe to clear CodeQL XSS on AddDeviceTypeMappingView

The previous `# lgtm[py/reflected-xss]` suppression is LGTM.com legacy syntax
and is not honored by GitHub's modern CodeQL action, so the alert returned.
The remaining taint path is request -> detail_view.get(request, device_id) /
render_device_row(request, ...) -> .content.decode() -> f-string into
HttpResponse; the inner views auto-escape via Django templates, but CodeQL
does not credit template rendering as a sanitizer at this composition site.

Compose the OOB envelope with format_html() and pass the rendered inner HTML
through mark_safe(). Both are recognized by CodeQL's Django XSS taint model
as sanitizers, and the assertion is accurate -- the inner HTML originates
from auto-escaping render() calls. Drop the stale comment block and the
ineffective lgtm suppression.

* fix: skip change-permission escalation in concurrent-create no-op path

When a concurrent request creates a DeviceTypeMapping between our upfront
read and the select_for_update() lock, and the locked row already maps to
the same device_type_id, the write block is a no-op (lines 1492-1495 skip
save). Escalating to change permission in that case rejects callers who
hold only add permission even though nothing would be mutated.

Short-circuit: only require change permission when locked.netbox_device_type_id
!= device_type_id (i.e. we will actually overwrite).

Also add CodeQL XSS suppression pattern to copilot-instructions.md.

* docs: clarify mark_safe is a trust assertion, not a sanitizer

CodeRabbit correctly flagged that the prior wording described mark_safe()
as a sanitizer. Rewrote the CodeQL section to be explicit: mark_safe() only
asserts that the caller has verified the string is safe; it must only be
used on server-rendered Django view output, never on raw user input.

* feat: add VC-aware module sync

* test: align VC module sync expectations

* fix: use all ancestor names as bay-mapping candidates

Previously _match_module_bay only used the nearest named ancestor as a
candidate for ModuleBayMapping lookups.  For deeply nested ports (e.g.
GigabitEthernet3/11 inside a CVR-X2-SFP adapter on a WS-X4908-10GE)
the immediate parent is Port Container 3/11, which the existing contrib
regex ^Port Container (\d+)/(\d+)$ resolves to X2 Port 11 — a bay that
does not exist.  The grandparent Port Container 3/2 would resolve
correctly to X2 Port 2, but was never tried.

Replace _find_parent_container_name (nearest-only) with
_find_all_ancestor_names which returns every named ancestor nearest
first.  All ancestors beyond the immediate parent are appended to
candidate_names after item_name/item_descr so that the existing contrib
regex already handles this case without any new mapping entries.

The contrib description for the Port Container regex is updated to note
the CVR-X2-SFP use-case.

* Revert "fix: use all ancestor names as bay-mapping candidates"

This reverts commit 216fb84358b347326e8983cb87f046c0fda8b7c4.

* test: add prod-shape WS-X4908 bay-matching coverage

The existing _linecard_inventory fixture uses synthetic container names
that already match NetBox bays directly ("Slot 3", "X2 Port 2", "SFP slot")
and never exercises the contrib regex paths or the positional fallback
on real LibreNMS naming.  As a result, no test covered the actual prod
data shape, and a flawed "walk all ancestors as bay candidates" fix
(216fb84, since reverted) passed all tests despite landing transceivers
in the wrong bay.

Capture the real shape from a Cisco WS-X4908-10GE linecard:

    chassis "Switch System"
      container "Slot 3"               [no model]
        module "Linecard(slot 3)"      [WS-X4908-10GE]
          container "Port Container 3/2"
            other "Converter 3/2"      [CVR-X2-SFP]
              container "Port Container 3/11"
                port "GigabitEthernet3/11"  [GLC-TE]
              container "Port Container 3/12"
                port "GigabitEthernet3/12"  [GLC-T]

Tests assert each level resolves correctly:
  - linecard via `^Linecard\(slot (\d+)\)$` regex -> device-bay "Slot 3"
  - converter via parent `^Port Container (\d+)/(\d+)$` regex -> "X2 Port 2"
  - GE inside CVR via positional fallback -> "SFP 1" / "SFP 2"
  - GE shows "No Bay" when CVR is matched but uninstalled in NetBox

A separate regression test uses a no-Converter-entry hierarchy
(Linecard -> PC 3/2 -> PC 3/11 -> GE3/11) where bay scope bubbles to
the linecard's bays.  In this scope, ancestor-walking would resolve
the grandparent `Port Container 3/2` to `X2 Port 2` and incorrectly
land the transceiver in the parent module's slot.  This test fails
if 216fb84-style logic is re-introduced.

Add a `_load_contrib_bay_mappings` helper that loads the contrib YAML
as fake mapping objects and a `bay_mappings=` kwarg on `_run_build_context`
so tests can opt into the real mapping set instead of the empty default.

* fix: bail _match_bay_by_position on non-container scaffolding

Cisco IOS-XR exposes deep entity-MIB scaffolding under each linecard:
modules with class="module" and model="N/A" (Motherboard, Slice 0,
EZChip, "Slice 0 SFP Port Module #N").  The positional walk previously
treated every model-less ancestor as a walk-through container, kept
reassigning container_idx until it found the linecard's real model,
and then took the position of the deepest ancestor among the linecard's
direct children.

On a real ASR-9904 (NetBox device prod-lab03d-ra1.lab) every TenGigE
port descended through the same Motherboard chain, so they all reported
container_idx=Motherboard and resolved to position=1 -> Slot 1 on the
chassis.  The chassis Slot 1 holds the RSP line card; clicking install
on a TenGigE row would write an SFP module into the RSP bay, then any
subsequent install rejected with "Module bay 'Slot 1' already has a
module installed".

Restrict the walk to ENTITY-MIB containers (entPhysicalClass="container").
A modelless ancestor of any other class is hierarchical scaffolding, not a
bay position; walking past it silently collapses sibling counts.  Bail
when we hit one — the row drops to "No Bay" instead of confidently
mismatching.

Tests:

- TestPositionalMatchScaffoldingChain (new): captures the ASR-9904
  shape and asserts (1) TenGigE rows do NOT match Slot 1, (2) status
  is "No Bay", (3) sibling rows resolve independently rather than
  collapsing to a single bay.

- TestMatchBayByPosition (updated): existing tests omitted
  entPhysicalClass on synthetic containers; add it explicitly so the
  fixtures match real LibreNMS data shape and the positional walk's
  class check passes.

Verified live: all 11 TenGigE0/0/0/N rows on device 54 now show
"No Bay" instead of collapsing to "Slot 1".  RSP0/RSP1 and power
supplies still match correctly via their own positional paths.

* fix: restrict serial_matches_device rule to chassis-level entries

The 'Embedded RP / fixed-chassis system board' ignore rule
(match_type=serial_matches_device, action=transparent) targets fixed-form
routers like Cisco 8201-SYS / 8100, where the system board is reported as
an ENTITY-MIB entry whose serial equals the device record's serial.
Marking it transparent hides the row and promotes its children
(transceivers, fans, PSUs) to device-level bay matching.

The match criterion was just "item.serial == device.serial" with no
location check.  On chassis devices like the ASR-9904, line cards can
share the device serial when the operator set Device.serial to the
linecard's serial (LibreNMS doesn't necessarily report the chassis serial
as the device serial).  The rule fired on the linecard, hid it, and
promoted every TenGigE child to chassis-level matching.

Combined with the previous positional-walk bug, this collapsed every
TenGigE0/0/0/N row to the same chassis Slot 1 bay; in isolation it would
silently land transceivers in chassis line-card bays.

Tighten the criterion: only fire when the item is at chassis level —
either it has no parent (top-level entity) or its direct parent has
entPhysicalClass="chassis".  System boards on fixed-form routers satisfy
this; line cards inside slot containers don't.

Tests cover three new shapes:
- parent is chassis -> rule fires (fixed-form router)
- parent is container -> rule does NOT fire (ASR-9904 linecard)
- parent is module -> rule does NOT fire (nested submodule)

Verified on live device 54: the 0/0 linecard now appears as its own
row instead of being hidden, and its TenGigE descendants resolve to
"No Bay" instead of collapsing to a chassis slot.

* fix: class-aware positional fallback + model gap warnings

The positional fallback in _match_bay_by_position previously tried
[SFP N, Slot N, Bay N, Port N] for every item regardless of hardware
class.  On chassis devices whose NetBox model defines only line-card
slots (Slot 0..3) but no Fan Tray / PSU bays, fans and power supplies
landed in line-card "Slot N" bays.  Example on ASR-9904 device 54:

  - 0/FT0          (fan)         -> Slot 3
  - 0/PT0-PM0      (powerSupply) -> Slot 2
  - 0/PT0-PM1      (powerSupply) -> Slot 3

Pick patterns appropriate for the item class:
  - fan          -> Fan Tray N / Fan N / FT N
  - powerSupply  -> Power Supply N / PSU N / PEM N / PM N
  - module / port / ioModule / cpmModule / mdaModule / fabricModule
    / xioModule -> Slot N / SFP N / Bay N / Port N
  - other classes (sensor, etc.) -> no positional guess

Items whose class has no matching bay name in scope drop to No Bay
rather than confidently mismatching.

Surface NetBox-model gaps: each No Bay / No Type row now carries a
model_warning field describing the most likely missing piece:

  - empty bay scope -> parent module type has no bay templates
  - class-specific  -> add bay templates with the expected names
  - missing type    -> No NetBox ModuleType matches '<model>'

The modules-table render_status surfaces this as a tooltip on the
status badge plus an alert icon, mirroring the existing
name_conflict_warning rendering.

Tests:
  - TestPositionalMatchClassAware: 6 cases covering fan/PSU/module
    behavior plus unknown-class fallback to None.
  - TestNoBayWarningHints / TestNoTypeWarningHints: helper output
    distinguishes the three causes.
  - TestBuildRowModelWarning: integration check that _build_row
    populates model_warning on the right rows.
  - test_tables_modules.py: render_status surfaces model_warning as
    a tooltip with the alert icon.

Verified on device 54: 0/FT0 and 0/PT0-PMx now show No Bay with
class-appropriate hints instead of landing in chassis line-card slots.

* feat: ModuleBayMapping suggestions + Add Mapping button on No Bay rows

When a row resolves to "No Bay" because the LibreNMS name doesn't match
any bay in scope, propose a regex ModuleBayMapping covering the whole
slot family rather than one entry per slot. Heuristic: when the item's
name ends with a number and a bay in scope ends with the same number,
suggest "^<prefix>(\d+)$ -> <bay-prefix>\1" with the item's class as
filter. Example: item "0/0" + bay "Slot 0" yields
"^0/(\d+)$ -> Slot \1, class=module".

Suppressed in three cases:
- scope_preserved=True (scope inherited from an unmatched ancestor)
- Class/bay mismatch (fans only match Fan/FT bays, etc.)
- scope_uninstalled (warning already redirects to install parent first)

UI:
- render_status surfaces the suggestion in the badge tooltip.
- render_actions adds an "Add Mapping" button on No Bay rows with
  model_suggestion. Opens ModuleBayMapping create form pre-filled via
  NetBox ObjectEditView GET-param initial. return_url is captured from
  configure(request) for round-trip.

Plumbing:
- _build_row gains scope_uninstalled and scope_preserved kwargs.
- The iteration loop tracks both states alongside bays_by_depth.
  Defaults fall back to top-level state so first sub-item iteration
  inherits correct semantics.
- _build_no_bay_warning gains a scope_uninstalled branch ("install the
  parent module first") and appends suggestion when provided.

Verified live on device 54 (ASR-9904):
- 0/0 (No Bay, top-level)            -> SUGGEST ^0/(\d+)$ -> Slot \1
- TenGigE0/0/0/N (preserved scope)   -> no suggestion
- 0/FT0 (fan, no fan bays)           -> no suggestion (class filter)
- 0/PT0-PMx (powerSupply)            -> no suggestion (class filter)

* fix: address valid code-review findings

- testing.instructions.md: add test_coverage_bulk_import.py and
  test_coverage_utils.py to coverage-by-module table
- models.py InterfaceTypeMapping.clean(): normalize/strip librenms_type
  and reject blank values before wildcard uniqueness check
- librenms_sync.js: always clear device_selection cell on row update,
  even when backend returns empty, so stale VC dropdowns are removed
- tables/modules.py VCModuleTable: hide device_selection column by
  default (visible=False); guard format_module_data against missing VC
- utils.py load_bay_mappings(): use explicit order_by for deterministic
  regex precedence
- modules_view.py: log raw LibreNMS errors server-side and show generic
  message to users (inventory fetch failure + transceiver fetch warning)
- test_coverage_filters.py: make cache-key assertions order-independent
- test_vm_operations.py: add missing existing_device key to mock dict

* feat: remove {module} conflict warning, add Generic manufacturer fallback for module type matching

* fix: replace {module} conflict tooltip with Name Conflict status badge; add Generic manufacturer fallback

- Remove the warning tooltip about {module} causing non-unique interface
  names; instead show a 'Name Conflict' status badge (bg-warning text-dark)
  when has_nested_name_conflict() detects sibling name collisions
- has_nested_name_conflict() and sibling_counts tracking are preserved
- Add get_generic_module_types_indexed() and generic_fallback support in
  resolve_module_type() so 'Generic' manufacturer matches are tried when
  no vendor-specific ModuleType is found
- Pre-fetch generic module types once per request in _get_generic_module_types()
- render_status() no longer reads name_conflict_warning (never set); reads
  model_warning only for the alert-icon tooltip
- Restore has_nest…
marcinpsk added a commit that referenced this pull request May 19, 2026
* feat(inventory): modules/inventory sync tab with mapping rules

Add inventory/modules sync functionality:
- Six mapping model types: DeviceTypeMapping, ModuleTypeMapping,
  ModuleBayMapping, NormalizationRule, InventoryIgnoreRule, PlatformMapping
- Migration 0010 creating all mapping tables
- Modules sync tab on Device/VM detail pages with ENTITY-MIB inventory data
- Install, replace, and move module actions
- Mapping CRUD views with YAML bulk export for all mapping models
- LibreNMS API: get_device_transceivers() for transceiver data
- Platform matching: PlatformMapping lookup before name-exact match
- contrib/ YAML files with example mappings and rules
- Comprehensive test coverage (test_sync_modules, test_modules_view,
  test_module_replace, test_platform_mapping, test_tables_modules)

* fix tests: update db-fallback tests to match new RQ-only cancellation behavior

_is_job_cancelled now returns False on RQ/Redis unavailability instead
of falling back to DB status. Update 5 tests that were asserting the
old DB-fallback behavior:

- test_db_fallback_logs_via_module_logger_when_job_logger_none →
  test_rq_unavailable_does_not_cancel_import: RQ unavailable means
  processing continues, not cancelled
- test_job_db_fallback_stopped/errored_before_validation_loop →
  test_job_cancelled_before_validation_loop_returns_empty /
  test_rq_unavailable_job_not_cancelled_in_preloop: patch
  _is_job_cancelled directly to test early-exit behavior
- test_job_validation_loop_db_fallback_stop →
  test_job_cancelled_in_validation_loop_returns_empty: use
  _is_job_cancelled side_effect to simulate mid-loop cancellation
- test_job_rq_check_exception_uses_db_status_and_exits →
  test_rq_fetch_exception_does_not_cancel_process_filters: assert
  result has 1 device (not []) when RQ unavailable

* fix: updated tests

* Apply CR findings: code quality, test, docs, and template fixes

- Remove dead _resolve_naming_preferences from actions.py (never called)
- Unify vc_detection_enabled parsing in BulkImportConfirmView (POST+GET)
- Add ambiguity detection to get_module_types_indexed second loop
- Fix get_validated_device_cache_key doctest (wrong e3b0 hash)
- Add status=='ok' envelope check before transceivers shape validation
- Strip netbox_bay_name in ModuleBayMapping.clean()
- Use server_info.server_key in _module_sync.html refresh form
- Guard module_mismatch_modal Update Serial form on serial_conflict/installed
- Remove duplicate NoSuchJobError import in test_coverage_api2.py
- Fix _is_job_cancelled side_effect count for in-loop cancellation test
- Precompute sibling_counts in _build_table_rows to eliminate N+1 queries
- Accept sibling_counts in has_nested_name_conflict (DB fallback preserved)
- Update test_has_installable_children fake_build_row signature
- Coerce entPhysicalParentRelPos to int in siblings sort (prevent TypeError)
- Move get_queue inside try block in api/views.py sync_job_status
- Replace MD5 with SHA256 for VC domain fingerprint (FIPS compliance)
- Fix test_role_is_read_from_validation to test validation dict path
- Add test_module_replace.py to docs/development/testing.md
- Add platform_mappings.yaml row to contrib/README.md
- Fix QSFP28-DD-2X100G-LR4 typo in module_type_mappings.yaml
- Clarify librenms_id auto-create in docs/usage_tips/custom_field.md

* Apply second batch of CR findings on pr/inventory-core

- Unify librenms_id auto-create version reference to 0.4.3 in docs
- Move _LIBRENMS_JOB_NAMES to module-level constant in api/views.py
- Add status=='ok' envelope check in port handler in librenms_api.py
- Split mapping ambiguity tracking in get_module_types_indexed (separate
  mapping_seen/mapping_ambiguous so explicit mappings always win over base
  ModuleType ambiguity)
- Handle match_type=='ambiguous' in find_matching_platform caller with
  dedicated warning message about conflicting platform mappings
- Move vc_requested parse before validate_device_for_import in
  BulkImportConfirmView and pass include_vc_detection=vc_requested
- Guard Replace Module form with {% if installed_module %} in
  module_mismatch_modal.html to prevent empty module_id submission
- Add mock_db_job.status/completed assertions to api2 test
- Extend cancellation test side_effects to 4 entries to target in-loop
  check (lines 507, 534, 566, 574) in both RQ and _is_job_cancelled tests
- Assert success list in test_no_warning_when_cluster_found

* Apply CR findings batch 3: test hardening, PlatformMapping lazy import, module bay normalization, BulkExportYAMLView permissions

- test_coverage_sync_views2: patch get_object_or_404 to isolate from ORM
- test_coverage_device_fields: assert save/full_clean not called on no-match
- test_librenms_id: use exact tuple set comparison for Q branch children
- test_init: assert DB alias used in custom field creation
- utils.py: move PlatformMapping import to function scope (lazy); update all test patches to models.PlatformMapping; remove create=True
- test_platform_mapping: patch require_object_permissions instead of require_write_permission
- test_sync_modules: mock apply_normalization_rules in _match_module_bay tests
- views/base/modules_view.py: apply NormalizationRule(scope=module_bay) to candidate names in _match_module_bay
- views/mapping_views.py: BulkExportYAMLView uses NetBoxObjectPermissionMixin with view permission
- views/object_sync/devices.py: precompute has_write_permission once in get_table()

* cr: batch 4 — prefetch_related, deterministic export, test improvements

- utils.py: add prefetch_related('interfacetemplates') to ModuleType query
  and prefetch_related('netbox_module_type__interfacetemplates') to
  ModuleTypeMapping query in get_module_types_indexed() to avoid N+1
  queries in has_nested_name_conflict()
- views/mapping_views.py: add .order_by('pk') to BulkExportYAMLView filter
  for deterministic YAML export; add select_related() to all 4 export
  subclass querysets (DeviceTypeMapping, ModuleTypeMapping,
  NormalizationRule, PlatformMapping)
- tests/test_utils.py: assert exact Platform.objects.get kwargs in 2 platform
  tests; fix vc.master = None -> vc.master = master to exercise designated-
  master-without-IP branch
- tests/test_platform_mapping.py: update mock chain for .order_by(); complete
  test_returns_yaml_content_type with actual view call and content-type assertion
- tests/test_sync_modules.py: fix mock chain for .prefetch_related() in
  TestGetModuleTypesIndexed

* cr: batch 5 — normalization scoping bug, test fixes

- utils.py: fix apply_normalization_rules() else-branch to filter
  manufacturer__isnull=True so callers without manufacturer context never
  have vendor-specific rules applied to their values
- tests/test_sync_modules.py: add test_mapping_overrides_ambiguous_base_key
  to lock down the separate-ambiguous-sets behaviour in
  get_module_types_indexed(); fix test_regex_mapping_with_backreference to
  use 'Optics 0/0/0/5' (with space) so the assertion cannot pass via
  exact-name fallback — only the regex expansion path can produce a match
- tests/test_platform_mapping.py: remove dangling assertions accidentally
  left inside test_returns_200_with_empty_selection (PlatformMapping import
  and existence check now live in test_all_mapping_bulk_export_yaml_views_exist)

* cr: batch 6 — normalization rule caching, test correctness

- utils.py: add preload_normalization_rules() helper that preloads
  NormalizationRule rows for a (scope, manufacturer) combination into a
  dict keyed by (scope, manufacturer_pk_or_None); update
  apply_normalization_rules() to accept preloaded_rules kwarg and use
  preloaded lists when provided (skipping DB queries); update
  resolve_module_type() to accept norm_rules kwarg and thread it through
  to apply_normalization_rules — eliminates N+1 DB queries in
  _match_module_bay and _build_row loops
- views/base/modules_view.py: call preload_normalization_rules() in
  _build_context for both 'module_bay' and 'module_type' scopes; pass
  preloaded rules via self._norm_rules_bay/_norm_rules_type to
  _match_module_bay and _build_row respectively
- tests/test_platform_mapping.py: add test_multiple_platform_mappings_returns_ambiguous
  asserting PlatformMapping.MultipleObjectsReturned yields
  match_type='ambiguous' and that Platform.objects.get is never called
- tests/test_sync_modules.py: fix test_mapping_overrides_ambiguous_base_key
  to use distinct mapping key 'SFP-1G-LX-EXPLICIT' so 'SFP-1G-LX' is
  absent and only the explicit key is present; fix
  test_uninstalled_bay_is_skipped to add grandparent bay with installed
  module (pk=99) and assert walk continues past empty bay to return 99;
  fix test_class_scoped_mapping_preferred to pass [m_generic, m_class] so
  priority logic must actively prefer class-scoped mapping; patch
  preload_normalization_rules in tests that call _build_context directly;
  update apply_normalization_rules lambda patches to accept **kw

* fix: FPC slot int/str comparison, stale docstring, test patch targets

- _fpc_slot_matches: convert match.group(1) and parent_bay.position to int
  before comparing (Python 3: '1' == 1 is False); handle ValueError with
  safe fallbacks
- apply_normalization_rules docstring: clarify that manufacturer=None applies
  only unscoped (manufacturer__isnull=True) rules, not all scope rules
- test_modules_view._run_build_context: patch load_bay_mappings and
  get_enabled_ignore_rules at utils level instead of patching model classes
  that _build_context never references directly; remove now-unused
  mock_ignore_qs variable

* fix: surface ambiguous platform, preloaded-rules fallback, serial mismatch, transceiver ignore

- actions.py sync_platform: add explicit elif for match_type='ambiguous' so
  users get a clear conflict message instead of generic 'not found' error when
  multiple PlatformMapping rows match the same OS string (works towards #51)
- utils.py apply_normalization_rules: when preloaded_rules is provided, check
  key presence before using the dict; fall back to DB query when (scope,
  mfg_pk) or (scope, None) is absent, preventing silent omission of vendor
  rules for manufacturers not included in the preloaded dict
- utils.py match_librenms_hardware_to_device_type: update Returns docstring to
  dict | None and document the MultipleObjectsReturned → None case
- modules_view.py _apply_installed_status: drop nb_serial from the guard so a
  module with a LibreNMS serial is flagged as Serial Mismatch even when NetBox
  has no serial recorded (lnms_serial and lnms_serial != nb_serial)
- modules_view.py _collect_top_items: apply ignore-rule check to transceiver-
  synthesised items before appending, so InventoryIgnoreRules can suppress
  optics from get_device_transceivers()
- test_modules_view.py: rename test that expected the old nb_serial-required
  behavior; update assertions to Serial Mismatch + can_update_serial + can_replace
- test_modules_view.py: wrap two early-return _detect_serial_conflicts tests in
  patch(dcim.models.Module) and assert filter was never called

* fix: canonical librenms_id lookup, None guard, transceiver transparent, vc flag case

- utils.py find_by_librenms_id: strip whitespace and canonicalize leading-zero
  string IDs before building Q filters; '042' and '42 ' now resolve to
  int_value=42 / canonical_str='42' and both forms are added to the query so
  they match records stored as numeric 42 or string '42'
- modules_view.py _find_parent_container_name: use (... or '') pattern to guard
  against entPhysicalName being explicitly None in the ENTITY-MIB payload
- modules_view.py _match_module_bay: same None guard for entPhysicalName,
  entPhysicalDescr, entPhysicalClass on the item dict
- modules_view.py _collect_top_items: treat 'transparent' the same as 'skip'
  for transceiver-synthesised rows so transparent synthetic items are not
  added to top_items
- actions.py BulkImportDevicesView: normalise vc_detection_enabled flag with
  .lower() before membership test so 'ON', 'True', 'TRUE' all parse correctly,
  consistent with BulkImportConfirmView

* Apply CR batch 10 fixes

- bulk_import: check cancellation every iteration (not every 5th)
- models: always call full_clean() on save, remove update_fields guard
- tables/modules: add has_write_permission param to LibreNMSModuleTable,
  gate selection column and render_actions on it
- modules_view: normalize placeholder model/serial strings in transceiver
  merge; filter placeholder serials from inv_serials set
- api/views: skip DB status update when job is already in a terminal state
- utils: add 'ambiguous' case to find_matching_platform docstring
- utils: memoize DB fallback into preloaded_rules in apply_normalization_rules
- utils: use try/except int() instead of isdigit() for +42 style IDs
- devices: pass has_write_permission to LibreNMSModuleTable constructor
- test_modules_view: use SimpleNamespace instead of MagicMock in
  _determine_status tests for unambiguous truthiness checks
- test_sync_modules: assert checkbox HTML in selection cell; update
  test_install_module_view_not_in_base to assert public import path;
  add order-independent check for class-scoped mapping preference
- test_tables_modules: set has_write_permission=True in _make_table helper;
  add no-write-permission test case
- test_utils: assert exact kwargs on DeviceTypeMapping.objects.get and
  PlatformMapping.objects.get calls
- test_vm_operations: patch _is_job_cancelled directly instead of mutating
  job.job.status via refresh_from_db side_effect
- test_coverage_base_views2: rename test to reflect actual behavior
  (cache entry present but lacks port_id)
- test_coverage_devices: update constructor assertion to include
  has_write_permission kwarg

* Apply CR batch 11 fixes

- models: add FullCleanOnSaveMixin + clean() to InterfaceTypeMapping to
  enforce uniqueness for NULL-speed rows (SQL UNIQUE skips NULL=NULL)
- utils: expand match_librenms_hardware_to_device_type docstring to
  document all three fail-closed None cases (mapping, part_number, model
  MultipleObjectsReturned), not just the mapping-table one
- test_vm_operations: remove stale mock_job.job.status='running' from
  _run_bulk_with_mappings helper; patch _is_job_cancelled=False instead

* fix: normalize librenms_hardware/os to lowercase, fix convert_speed_to_kbps docstring

- DeviceTypeMapping.clean() and PlatformMapping.clean() now lowercase
  the stored value after stripping, preventing case-variant duplicates
  (e.g. 'IOS' and 'ios') that would cause MultipleObjectsReturned on
  __iexact lookups. Closes #51.
- Fix convert_speed_to_kbps docstring: Returns section now reads
  'int | None' to match the signature and implementation.
- Add TestDeviceTypeMappingModel tests for DeviceTypeMapping.clean()
  (strip, lowercase, blank validation).
- Add test_clean_normalizes_to_lowercase and test_clean_strips_and_lowercases
  to TestPlatformMappingModel.

* fix(js): address PR #258 review findings

- hideModal: remove all backdrops via querySelectorAll+forEach
- htmx:afterSettle: derive label from aria-labelledby, fallback to id
- initializeVlanModalSave: truncate/extract error body before display

* fix: handle unsaved manufacturer in normalization, fix JS modal/fetch issues

- utils.py: guard unsaved manufacturer (pk=None) in preload_normalization_rules
  and apply_normalization_rules to prevent ValueError on DB query
- librenms_sync.js: add id="htmx-modal-label" to module mismatch modal header
  so aria-labelledby target is preserved after innerHTML replacement
- librenms_sync.js: check response.ok before response.json() in deleteUrl fetch
  so HTTP errors surface their status instead of a parse error

* fix: type annotation, non-positive librenms_id guard, htmx label listener

- utils.py: fix convert_speed_to_kbps parameter annotation to int|None
- utils.py: guard non-positive numeric librenms_id (<=0) same as None;
  skip Q canonicalization for string '0'/'-1' after int parse
- librenms_sync.js: extract updateHtmxModalLabel(), listen at document
  level for htmx:afterSettle, call from module-replace fetch completion

* fix: use _PLACEHOLDER_VALUES for serial normalization, fix docstring

- modules_view.py: _apply_installed_status and _detect_serial_conflicts
  now use _PLACEHOLDER_VALUES set instead of only guarding against "-"
  so serials like 'unknown', 'n/a', 'na' are treated as absent
- utils.py: update convert_speed_to_kbps Args docstring to int|None

* Fix CR batch: device_type ambiguity, platform MOR, cache invalidation, ChainMap bay lookup

- device_fields.py: distinguish None (ambiguous) from failed match result;
  surface a specific error for duplicate DeviceType mappings
- utils.py: find_matching_platform returns {found:False, match_type='ambiguous'}
  on Platform.MultipleObjectsReturned instead of silently taking .first()
- modules_view.py: invalidate stale inventory cache before early-return renders
  when librenms_id is falsy or inventory fetch fails
- modules_view.py: _lookup_regex_bay_mapping iterates all ChainMap scopes
  so same-named bays in different scopes are all checked
- tests: update TestFindMatchingPlatformMultipleReturned to expect ambiguous

* Promote SKIP_TYPES and _NON_HARDWARE_CLASSES to module-level constants

Move inline set literals SKIP_TYPES and _NON_HARDWARE_CLASSES out of their
method bodies and into module scope, consistent with _PLACEHOLDER_VALUES and
other module-level constants. Rename SKIP_TYPES to _SKIP_TRANSCEIVER_TYPES
to clarify its domain.

* Fix find_matching_platform docstring and non-positive string librenms_id guard

- utils.py: broaden find_matching_platform docstring to state 'ambiguous'
  applies both to multiple PlatformMapping entries and to duplicate
  exact-name Platform rows (Platform.MultipleObjectsReturned)
- utils.py: add early-return guard in find_by_librenms_id for string
  librenms_id values that parse to <= 0 (e.g. '0', '-1', '000'), preventing
  them from reaching the Q clauses and matching stale/corrupted records

* fix: normalize placeholder serials and txr_type in modules_view

_check_ignore_rules: normalize item_serial, device_serial, and
ancestor_serial against _PLACEHOLDER_VALUES so sentinels like
'unknown'/'n/a' are treated as absent and do not trigger or
short-circuit serial_matches_device rules or require_serial_match_parent
ancestor walks.

_merge_transceiver_data: normalize txr_type against _PLACEHOLDER_VALUES
(same as model/serial) so placeholder types like 'unknown' do not bypass
the _SKIP_TRANSCEIVER_TYPES guard and produce synthetic rows with
display_model set to a placeholder string.

* Fix whitespace-only librenms_id, BUILTIN model placeholder, FPC-scope exact-bay fallback, has_write_permission in HTMX render

- utils.py: treat whitespace-only librenms_id strings as absent (return None
  after strip() when cleaned == "") so they don't reach the Q-object builder
- modules_view.py: extend transceiver backfill to also replace 'BUILTIN' model
  and serial values, not just those already in _PLACEHOLDER_VALUES
- modules_view.py: exact-name bay fallback now iterates ChainMap scopes and
  calls _fpc_slot_matches() to avoid returning the wrong-scope bay when
  duplicate bay names exist across scopes (mirrors the regex path behaviour)
- modules_view.py: add has_write_permission to all render() calls in post()
  so the Install Selected button is visible in HTMX-refreshed content

* Fix non-integer librenms_id passthrough, stale cache on missing ID, exact-mapping ChainMap scope

- utils.py: non-integer strings (e.g. 'abc') now return None in
  find_by_librenms_id() instead of falling through to build Q objects;
  changed 'except ValueError: pass' to 'except ValueError: return None'
- modules_view.py: get_context_data() re-validates the device's current
  LibreNMS ID before serving cached inventory; clears cache and returns
  empty context when the mapping has been removed since the cache was written
- modules_view.py: _lookup_exact_bay_mapping() now iterates ChainMap scopes
  and calls _fpc_slot_matches() before returning, matching the existing
  behaviour of _lookup_regex_bay_mapping() and the name-fallback path

* fix: normalize _GENERIC_CONTAINER_MODELS to lowercase; embed librenms_id in inventory cache

- modules_view: lowercase _GENERIC_CONTAINER_MODELS set and apply .lower() at
  all 5 comparison sites so values like 'builtin', 'default', 'n/a' received
  from LibreNMS in any case are treated as generic containers
- modules_view: store {'inventory': data, 'librenms_id': id} in the inventory
  cache instead of the raw list; get_context_data validates the embedded
  librenms_id against the current mapping so remapped devices never serve stale
  inventory (non-dict/legacy entries are treated as cache misses)

* fix: treat duplicate-serial module conflicts as ambiguous instead of silently overwriting

When multiple Module objects share the same serial, the old loop would
overwrite row["serial_conflict_module"] nondeterministically. Now we
group conflicts by serial and only set the move target when exactly one
candidate exists; multiple candidates set serial_conflict_ambiguous.

* fix: clear stale cache on legacy payload; ungate generic-model filter from phys class

- modules_view: get_context_data now calls cache.delete(cache_key) before
  returning when the cached payload is not the new dict format so pre-upgrade
  list-form entries are evicted and the next request regenerates fresh data
- modules_view: collapse the two-check current-item filter into a single
  'model in _GENERIC_CONTAINER_MODELS' test (removes the phys_class=='container'
  gate) so empty-model non-container items are also treated as generic
- modules_view: remove the anc_class=='container' guard from the ancestor walk
  so any inventory-class ancestor with a generic model (e.g. a 'module' row
  with model='builtin') is treated as transparent instead of blocking its
  subtree

* Remove dead vc_requested assignment left by rebase

The rebase onto pr/code-quality-fixes dropped the consumer of vc_requested
in favor of the hoisted vc_detection_enabled variable, leaving the
assignment itself as an orphan that ruff F841 flags.

* fix: VC zero-based detection all-zeros guard; align VC-perm tests with fail-closed behavior

- virtual_chassis.py: only treat stack as 0-based when positions span 0 AND a
  positive value. When every entPhysicalParentRelPos is 0 the data is invalid
  and the shift produced colliding positions (all members → slot 1); fall
  through to the per-member idx+1 fallback instead.
- test_coverage_bulk_import.py: PR #257 fails stack imports fast when the
  user lacks dcim.add_virtualchassis. Update both VC-permission tests to
  assert failure + error logging instead of silent success.

* fix: CR batch 12 — serial normalization, conflict disambiguation, modal guard, test fixes

- sync/modules.py: replace all 'serial == "-"' guards with _PLACEHOLDER_VALUES
  normalization (import from modules_view) for consistency across InstallModuleView,
  InstallBranchView, PreviewModuleReplaceView, ReplaceModuleView
- sync/modules.py: PreviewModuleReplaceView — use count() instead of .first() to
  detect ambiguous serial conflicts; pass serial_conflict_ambiguous=True to template
- sync/modules.py: ReplaceModuleView — use count() to detect ambiguous conflicts;
  return error redirect when count > 1 instead of silently picking one
- module_mismatch_modal.html: add serial_conflict_ambiguous branch (warning alert);
  gate 'Update Serial Only' and 'Replace Module' buttons when conflict is ambiguous
- tables/mappings.py: render em-dash for serial_matches_device rules in
  render_require_serial_match_parent (flag has no effect for that match type)
- test_coverage_bulk_import.py: remove xfail marker — TTL refresh is now implemented
- test_coverage_utils.py: assert PlatformMapping.objects.get called with librenms_os__iexact
- test_modules_view.py: assert row.get('serial_conflict_module') is None (not 'not in row')
- test_module_replace.py: add count.return_value to mock chain for new count() calls
- tests/e2e/test_module_install.py: extend os.environ instead of replacing in subprocess

* fix: address deferred CR findings from issues #53-#56

where can_move_from and serial_conflict_module are set. Button posts to
move_module view with conflict_module_id and target_bay_id.

valid dict from fetch_device_with_cache mock instead of None, so the
test exercises the full sync code path including cache dict population.

- save.assert_called_once() → assert_called_once_with(update_fields=
  ['status', 'completed']) in test_does_not_overwrite_completed_when_not_in_rq
- Use distinct server_key 'prod-server' in DeviceModuleTableView tests
  instead of 'default' to catch accidental default fallback
- Use permission-specific has_perm side_effect instead of return_value=True

per name (dict[str, list]) instead of keeping only the first. Resolve
mapping-based lookups by checking which bay has an installed_module when
duplicates exist — return only if unambiguous (exactly one occupied bay).

Closes #53
Closes #54
Closes #55
Closes #56

* fix: address 3 remaining CR findings from PR #50

test_coverage_actions.py: Fix false-positive test — wrong POST key
'vc_detection_enabled' replaced with the actual key 'enable_vc_detection'
that _resolve_vc_detection_enabled() reads. Previously the mock returned
None for every key and the assertion passed via the default=False fallback.

views/sync/modules.py: Unwrap cached inventory dict payload before use.
The inventory cache stores {"inventory": [...], "librenms_id": ...} but all
4 sync action views (InstallBranchView, InstallSelectedView,
ModuleMismatchPreviewView, ReplaceModuleView) iterated the raw payload as
a list, causing item.get("entPhysicalIndex") to iterate dict keys instead
of inventory rows. Added _extract_inventory_list() helper that unwraps the
dict payload and tolerates legacy list payloads.

views/sync/modules.py: Apply ignore rules to root item in _collect_branch().
Previously only children were filtered by ignore rules; the root/parent
item was always enqueued. Now _collect_branch() checks the root item:
'skip' → return []; 'transparent' → collect children only, not root.
Also updated InstallSelectedView to filter both 'skip' and 'transparent'
(was only 'skip') to match table view parity.

* refactor: drop _extract_inventory_list legacy-list fallback

The "legacy" list payload referenced in e96b3f3 never existed in any
released or upstream code — the bare-list writer was replaced inside
this same branch (7dfd436) before any consumer shipped, so no
deployed cache can hold one. The fallback also contradicted
BaseModuleTableView.get_context_data, which evicts non-dict payloads
as cache misses.

Drop the `or []` non-dict branch so the helper matches the canonical
reader, and update the inventory-cache stubs in test_module_replace.py
and test_sync_modules.py to use the dict format produced by the writer.

* fix: race conditions, class-aware mappings, stale snapshot in module sync

_install_single: Lock target bay with select_for_update() before checking
occupancy. Previously two concurrent requests could both observe the bay
as empty, causing an IntegrityError that rolled back the entire batch.
Now consistent with InstallModuleView's locking pattern.

_find_parent_module_id: Make ancestor mapping resolution class-aware.
Exact mappings are now indexed by (librenms_name, librenms_class) with
class-specific preference and class-empty fallback. Regex mappings also
prefer class-matching rules before falling back to class-empty rules,
consistent with _lookup_regex_bay_mapping() in the base view.

ReplaceModuleView: Move target_bay/old_type_name/old_bay_name reads
after select_for_update() so they reflect the locked row's current state.
Previously these were captured from the pre-lock snapshot, which could
be stale if another request moved or replaced the module first.

MoveModuleView: Lock target bay with select_for_update() inside the
atomic block instead of using the pre-lock get_object_or_404 snapshot.

Tests updated for select_for_update chains and librenms_class on mock
mapping helpers.

* fix: normalize placeholder serials in UpdateModuleSerialView, fix regex fallback in _find_parent_module_id

UpdateModuleSerialView: add placeholder normalization consistent with all
other serial-handling paths (InstallModuleView, _install_single,
ReplaceModuleView, ModuleMismatchPreviewView).

_find_parent_module_id: replace 'class_matches or fallback_matches' with
'class_matches + fallback_matches' so empty-class regex rules are still
tried when class-specific rules exist but none match the name — matching
the base view's _lookup_regex_bay_mapping pattern.

* fix: lock module row before updating serial in UpdateModuleSerialView

Move Module fetch inside transaction.atomic() with select_for_update()
to prevent stale-instance writes when a concurrent replace/move changes
the module between the read and the save. Consistent with locking pattern
used by InstallModuleView, ReplaceModuleView, and MoveModuleView.

Update test to mock Module.objects.select_for_update() chain instead of
get_object_or_404 for the module.

* fix: use fullmatch+expand in _find_parent_module_id regex matching

Mirror base view's _lookup_regex_bay_mapping behavior:
- Use rm._compiled_pattern.fullmatch(name) instead of re.search()
- Use match.expand(rm.netbox_bay_name) for capture group substitution
- Prevents false-positive partial matches and enables regex backrefs

Update test helpers to set _compiled_pattern on mock regex mappings.

* fix: address CR review batch - regex, tests, docs

- Tighten Nokia regex: \w → [0-9] for part number digits, [A-Z0-9]
  for transceiver cleanup pattern
- Use real dict for request.GET mock in test_background_jobs.py
- Make devcontainer discovery deterministic: env var override,
  error on multiple matches
- Fix brittle '156 objects' banner filter in e2e tests
- Fix typos in virtual_chassis.md (NEtbox → NetBox, dispalys)
- Make README install snippet idempotent with grep guard

* revert: restore original docs/usage_tips/virtual_chassis.md

Revert grammar/typo edits to virtual_chassis.md — these changes
do not belong in this PR. Any docs updates should go through the
upstream repository directly.

* fix: revert bulk_import cancellation, VC comment, VM docstring, sync template improvements

- Restore periodic job cancellation checks (every 5th iteration)
- Improve VC zero-based position detection comment clarity
- Simplify VM create docstring, reorder server_key parameter
- Use resolved_name instead of sysName in sync template
- Move VC serial count badge to button label
- Add name check tooltip

* fix: restore naming resolution and VC logic lost during rebase

Rebase regression stripped resolve_naming_preferences(),
_determine_device_name(), and _generate_vc_member_name() from both
UpdateDeviceNameView and the sync base view's get_librenms_device_info().

Restored:
- resolved_name computation using user naming preferences (sysName vs
  hostname, strip domain) in librenms_sync_view.py
- VC member name generation for virtual chassis devices
- VC sync device lookup for members without their own librenms_id
- resolved_name template context variable (used by sync_base template)
- Proper early bailout when LibreNMS has no usable name

Updated test mocks to set virtual_chassis=None and patch the restored
naming functions.

* revert: restore original README.md and virtual_chassis.md

These files should not be modified in this PR — docs and README
changes belong in upstream repository directly.

* fix: improve ambiguity test assertion and error message wording

- Rename test to test_match_none_returns_ambiguous_error and assert
  'Ambiguous' appears in the error message to detect regressions
- Broaden error message to cover both DeviceTypeMapping and DeviceType
  ambiguity (match_librenms_hardware_to_device_type returns None for
  either case)

* fix: CR review - modal close, test line refs, generic e2e

- librenms_sync.js: replace closeBtn.click() with hideModal() in VLAN
  modal save handler (aligns with upstream fix 7bf533f)
- test_coverage_bulk_import/devices/list: strip hardcoded line-number
  references from docstrings to avoid drift
- tests/e2e/test_module_install: make device-agnostic — auto-detect
  device, discover modules from table, generic assertions

* fix: three bugs in module inventory sync reported in PR #261 review

- select_for_update(of=("self",)) avoids FOR UPDATE on the nullable side
  of the installed_module outer join, fixing the Install Selected crash
- add has_write_permission to full-page context so Install Selected form
  renders on page load, not only after an htmx Refresh Modules swap
- guard htmx.process() with typeof check to prevent 'htmx is not defined'
  error when the Replace modal fetches its preview fragment

* fix: improve module table readability in dark mode

Remove table-success row highlight from installed modules — the Installed
badge is sufficient to identify state, and the green row background makes
linked text unreadable in dark theme.

Add text-white/text-dark contrast classes to all badge colors so they
remain legible in both light and dark themes.

* feat: split Mappings into its own navigation group

Move all mapping items (Interface, Device Type, Module Type, Module Bay,
Normalization Rules, Inventory Ignore Rules, Platform) out of the
Settings group into a dedicated Mappings group for clearer navigation.

* refactor: reorder navigation groups to Import, Status Check, Mappings, Settings

* fix: remove table-light from mismatch modal thead for dark mode

table-light forces a white background regardless of theme. Removing it
lets Bootstrap 5.3 theme-aware styling apply to the header row.

* fix: replace table-danger/warning cell backgrounds with text colors in mismatch modal

table-danger/table-warning produce a pinkish/yellow background that
clashes with Bootstrap's link color in dark mode. Switch to text-danger
and text-warning with fw-semibold — pure text color works on any
background and reads correctly in both light and dark themes.

* fix: remove all row background highlighting from module sync table

table-warning and table-danger row classes cause unreadable contrast in
dark mode. The status badge is sufficient to identify each row state.
Remove the row_class field entirely along with the dead row_attrs entry.

* refactor: reorder Mappings group — all mappings first, then Ignore Rules, Normalization Rules

* fix: update tests to reflect row_class removal

Replace assertions on table-success/danger/warning row_class values
with assert "row_class" not in row now that row highlighting is gone.

* fix: auto-generate slug when creating platform from sync page (#279)

Platform() without a slug fails full_clean() in NetBox 4.x. Derive the
slug from the platform name via slugify so the modal submit succeeds.

* Revert "fix: auto-generate slug when creating platform from sync page (#279)"

This reverts commit 3029cd3c2fc7ef054104e721a663ccde2109bdbb.

* fix: generate slug when creating Platform via CreateAndAssignPlatformView

Platform requires a slug field; omitting it caused full_clean() to raise
a ValidationError before the platform could be saved. Fixes #279.

* test: assert Platform constructor receives slug in CreateAndAssignPlatformView

Regression test for #279 — verifies slugify(name) is passed to the
Platform constructor so the missing-slug error cannot recur undetected.

* fix: generate slug when creating Platform via CreateAndAssignPlatformView

Platform requires a slug field; omitting it caused full_clean() to raise
a ValidationError before the platform could be saved. Fixes #279.

* test: assert Platform constructor receives slug in CreateAndAssignPlatformView

Regression test for #279 — verifies slugify(name) is passed to the
Platform constructor so the missing-slug error cannot recur undetected.

* fix: wrap OOB row in table to survive HTMX HTML parsing

When AddDeviceTypeMappingView returns a combined response with both
the modal OOB (a <div>) and the row OOB (a <tr>), HTMX wraps the
response in a <template> for parsing. A <tr> following a <div> in
that context is invalid HTML and gets silently dropped by the browser
parser, so the row never updates.

Wrap row_html in <table><tbody>...</tbody></table> before appending
to ensure the <tr> element survives parsing. The <div id='django-messages'>
inside is foster-parented outside the table by the parser, so both
OOBs are found and applied correctly.

* fix(imports): add HTTP status codes, remove redundant full_clean, fix JS Content-Type case-sensitivity

- AddDeviceTypeMappingView: return 400 for missing/invalid device_type_id,
  404 for DeviceType.DoesNotExist, 500 for unexpected exceptions
- Remove redundant mapping.full_clean() before save() — DeviceTypeMapping
  inherits FullCleanOnSaveMixin which calls full_clean() inside save()
- librenms_sync.js: lowercase Content-Type header before .includes() check
  to handle case variants (e.g. 'Application/JSON')

* fix(js): extract JSON error message in delete-interfaces handler; guard updateHtmxModalLabel self-assignment

- DeleteNetBoxInterfacesView fetch: parse JSON error body (error/message/detail)
  instead of throwing raw JSON string, consistent with other fetch handlers
- updateHtmxModalLabel: skip textContent assignment when header === label to
  prevent stripping icon markup from the modal title element

* refactor(js): extract fetchErrorMessage() helper to unify fetch error handling

inline text/JSON handling. Helper extracts error/message/detail from
JSON responses, falls back to raw text, and truncates at 300 chars.
Eliminates drift between handlers and simplifies future additions.

* feat: exact-name-first platform lookup; optional mapping on platform create

- find_matching_platform(): try exact case-insensitive name match first,
  fall back to PlatformMapping only when no direct name match exists;
  update docstring accordingly
- _get_platform_info(): delegate to find_matching_platform() instead of
  inline Platform.objects.get(); use 'or "-"' for null LibreNMS fields
- CreateAndAssignPlatformView: read 'librenms_os' + 'create_mapping' POST
  params; create PlatformMapping(librenms_os, platform) when checkbox
  checked and no existing mapping for that OS
- librenms_sync_base.html: add librenms_os hidden input, 'Add platform
  mapping' checkbox, and JS warning when platform name differs from OS
- device_validation_details.html: inline device-type search form when
  no matching device type
- urls.py + views/__init__.py: register and export AddDeviceTypeMappingView
- tests: align mocks and assertions to new lookup order

* fix(pr#50): address CR feedback on AddDeviceTypeMapping form and platform-mapping creation

- device_validation_details.html: add visually-hidden <label> for the
  device-type search input (HTMLHint a11y)
- device_validation_details.html: replace hardcoded "/api/dcim/device-types/"
  with {% url 'dcim-api:devicetype-list' %} so NetBox BASE_PATH deployments
  work correctly
- CreateAndAssignPlatformView: surface PlatformMapping creation failures
  to the user via messages.warning, and inform when an existing mapping
  was found (no longer fails silently)

* fix(pr#50): isolate PlatformMapping save; ignore stale autocomplete results

- CreateAndAssignPlatformView: wrap PlatformMapping save in nested
  transaction.atomic() so an IntegrityError on the unique librenms_os
  constraint only rolls back the mapping savepoint, not the outer
  device-platform-assignment transaction (which was previously left in
  needs_rollback state, so messages.success would fire after a discarded
  device save)
- device_validation_details.html: add monotonically increasing requestSeq
  to the device-type autocomplete so out-of-order fetch responses can no
  longer overwrite the dropdown with results for an older query

* fix(pr#50): check add_platformmapping permission when create_mapping is requested

CreateAndAssignPlatformView now reads 'create_mapping' from POST before the
permission check and dynamically adds ('add', PlatformMapping) to
required_object_permissions so require_all_permissions() enforces it.
Without this, a user with only add_platform + change_device could silently
create PlatformMapping objects through the checkbox.

* fix: address CodeQL security scan findings

- XSS (#18): escape() user-controlled error strings in bulk confirm HTML response
- XSS (#19,#20): escape() object_type param in device_fields.py HTTP responses
- Stack trace (#8): replace str(exc) with generic message in bulk import HTMX error
- Stack trace (#9): replace str(exc) with generic message in interfaces transaction error
- Stack trace (#13): replace catch-all str(e) with generic message in ip_addresses_view
- JS XSS (#4,#5): fix innerHTML spinner — store/restore full innerHTML, use DOM
  for label to avoid reinterpreting textContent as HTML
- Workflow permissions (#1-#3): add permissions: contents: read to all three workflows;
  publish-pypi job-level permissions also gains contents: read alongside id-token: write
- Devcontainer logging (#17): stop printing raw codespace name / CSRF / allowed-host
  values to stdout in devcontainer config
- URL redirect (#6,#7): add comment — _get_safe_redirect_url already validates via
  url_has_allowed_host_and_scheme; CodeQL false positive
- Lint: fix E741 ambiguous variable name in e2e test

* revert: remove workflow permissions additions (tracked separately)

* Fix PR review findings: available_roles, CSRF, test quality

- bulk_import.py: preserve available_roles in elif-not-actual_is_vm branch
  (matches pattern of other clearing paths at lines 363, 384)
- librenms_sync.js: remove getCookie('csrftoken') fallback from two fetch
  call sites; align with hidden-input-only CSRF convention used elsewhere
- test_vm_operations.py: strengthen log assertion to assert_any_call with
  expected checkpoint messages (VM 1 and VM 5 of 10)
- test_vm_operations.py: delete duplicate test_job_cancellation_with_errored_status
  (identical logic already covered by test_job_cancellation_breaks_loop)
- Skip mock-target change: apply_cluster/role_to_validation are lazy (in-function)
  imports, so patching import_validation_helpers.X is correct — patching
  vm_operations.X would fail as those names don't exist at module level

* Fix PR #58 review findings (batch 2)

- librenms_sync_view.py: Fix inverted comment on find_matching_platform order
- actions.py: Replace str(exc) with generic message in non-HTMX bulk import error handler
- actions.py: AddDeviceTypeMappingView — honour server_key, add NetBoxObjectPermissionMixin
  with DeviceTypeMapping add/change permissions
- device_validation_details.html: Add hidden server_key input to dt-mapping form
- interfaces.py: Add logger.exception in DeleteNetBoxInterfacesView catch-all
- test_sync_devices.py: Strengthen VM type assertion to assert_called_once_with
  including VirtualMachine class and pk kwarg

* Fix PR #58 review findings (batch 3)

- device_validation_details.html: Disable 'Add Mapping' button until device type
  is selected from dropdown; enable on selection, re-disable on input clear
- devices.py: Use copy.copy(request) for child table view instances, consistent
  with vms.py pattern
- test_coverage_devices.py: Update request assertions to use == (copy equality)
  instead of 'is' identity to match new copy.copy behaviour

* Fix #59–#62: YAML anchor, DynamicModelChoiceField, ToggleColumn, write perms

Closes #59: Anchor Juniper MX regex pattern (^...$) so generic transceiver
pattern sorts first and MX-specific fallback works correctly.

Closes #60: Replace plain ModelChoiceField with DynamicModelChoiceField in
DeviceTypeMappingForm and ModuleTypeMappingForm for API-backed typeahead.

Closes #61: Add 'pk' to fields/default_columns in all 7 mapping tables so
NetBoxTable's built-in ToggleColumn renders the bulk-select checkbox.

Closes #62: Add LibreNMSWritePermissionMixin (permission_required =
PERM_CHANGE_PLUGIN) to mixins.py and apply it to all 35 mutation views
(Create, Edit, Delete, BulkImport, BulkDelete) in mapping_views.py.

* Fix PR #63 review findings: ordering, error handling, modal title, click listener cleanup

- utils.py: add .order_by('pk') to get_enabled_ignore_rules() for deterministic
  first-match-wins behavior in _check_ignore_rules
- utils.py: add ambiguity_source key to find_matching_platform ambiguous returns
  ('platform' or 'mapping') to distinguish which source caused the conflict
- modules_view.py: _apply_installed_status else branch returns 'No Type' instead
  of 'Installed' when matched_type is None
- actions.py: AddDeviceTypeMappingView exception handler uses logger.exception
  and returns generic message instead of leaking str(exc) to the client
- device_fields.py: separate IntegrityError from ValidationError in
  CreateAndAssignPlatformView — IntegrityError on PlatformMapping insert treated
  as mapping_existed (concurrent insert) rather than a creation failure
- librenms_sync.js: updateHtmxModalLabel scopes .modal-title query to
  #htmx-modal-body to avoid matching the outer #htmx-modal-label element
- device_validation_details.html: replace anonymous accumulating document click
  listener with a named handleDocumentClick function that removes itself when
  the dropdown element is detached from the DOM (HTMX fragment replacement)
- test_coverage_forms.py: use _make_form helper in test methods instead of
  duplicating the patch setup inline
- test_platform_mapping.py: update ambiguous assertion to include ambiguity_source
- test_sync_modules.py: fix InventoryIgnoreRule mock to support .order_by() chain
- contrib/platform_mappings.yaml: create example file referenced in README

* Fix replacement template validation and deterministic bay lookup

Closes #64, Closes #65

Issue #64: ModuleBayMapping.clean() and NormalizationRule.clean() validated
replacement templates against test strings that didn't guarantee a regex match
(empty string, or pattern text itself), so invalid back-references like \2
on a single-group pattern silently passed. Add _validate_replacement_template()
helper that builds a synthetic guaranteed-match pattern with identical group
structure (named groups preserved), exercising all back-references.

Issue #65: _build_table_rows() used ChainMap(device_bays, *module_scoped_bays.values())
for transceiver items, whose iteration order was non-deterministic (dict insertion
order = queryset order). Replace with a dedicated _compute_all_bays() static
method that sorts module IDs by PK for stable first-match-wins collision
resolution, logs collisions at DEBUG level, and always lets device-level bays
win. Remove the now-unused 'from collections import ChainMap' import.

* Fix wildcard constraint, ambiguity message, and scoped permissions

Add DB-level UniqueConstraint for wildcard InterfaceTypeMapping rows
(librenms_speed IS NULL) to prevent concurrent duplicate inserts that
bypass the in-process clean() check. The existing constraint for
non-NULL rows gains an explicit condition so both are partial indexes.
Migration 0011 handles the rename + addition atomically.

Update sync_platform ambiguous-match error message to inspect
ambiguity_source ('platform' vs 'mapping') and direct users to either
the Platforms screen or the Platform Mappings screen as appropriate.

Restructure AddDeviceTypeMappingView.post() so only the plugin write
permission is checked upfront (cheap). The API call and hardware string
extraction happen first, then an existence check on DeviceTypeMapping
determines whether 'add' or 'change' object permission is required
before the actual get_or_create.

* devcontainer: restore full debug output in codespaces-configuration.py

Development-only file; logging config values (CSRF origins, allowed hosts)
is an accepted tradeoff. CodeQL alert dismissed intentionally.

* fix(migration): add preflight dedup before wildcard UniqueConstraint

Before enforcing unique_interface_type_mapping_wildcard, remove any
duplicate librenms_speed IS NULL rows (keeping lowest PK per
librenms_type). Guards against deployments that applied 0010 and hit
the race window before 0011 was available.

* fix: remove dead check_match, close TOCTOU race, fix migration db alias

- Remove InventoryIgnoreRule.check_match() — dead code never called
  anywhere; the real matching logic with require_serial_match_parent
  lives in _check_ignore_rules() in modules_view.py
- Close add→change permission race in AddDeviceTypeMappingView: use
  select_for_update() inside transaction.atomic() and re-check change
  permission if a concurrent request created the mapping in the window
  between the upfront filter() and the write
- Use schema_editor.connection.alias for ORM reads/deletes in
  remove_wildcard_duplicates migration so multi-DB deployments clean
  up duplicates against the correct database

* fix: guard symmetric delete race in AddDeviceTypeMappingView

If existing_mapping was found upfront (change permission granted) but
the row was deleted before select_for_update(), the else branch would
create a new row without ever requiring add permission. Add a symmetric
guard: if existing_mapping and not locked, re-check add permission
before the create path runs.

* fix: catch IntegrityError on concurrent create in AddDeviceTypeMappingView

select_for_update() cannot lock absent rows, so two concurrent requests
both seeing no existing mapping can both attempt .create(). Catch
IntegrityError inside the transaction and return 409 so the client
retries; the second attempt will find the now-existing row and take the
update path with correct change-permission checking.

Also move IntegrityError import to top-level (was a deferred local
import inside _save_device).

* fix: inject hx-swap-oob on device row in AddDeviceTypeMappingView response

The <table><tbody> wrapper was already in place to keep the <tr> in a valid
HTML context for the browser parser, but the hx-swap-oob attribute was never
injected, so HTMX had no instruction to OOB-swap the background row. Without
it the row stays stale until a manual refresh.

String-replace the known <tr id="device-row-{id}"> prefix (count=1) before
wrapping, so both the modal and the row are updated in a single response.

* fix: use int:device_id converter on all device-import URL patterns

CodeQL flagged reflected XSS on AddDeviceTypeMappingView because device_id
was <str:device_id>, making it a user-controlled string embedded in the
HTML response. LibreNMS device IDs are always integers; switching to
<int:device_id> on all seven device-import/* routes gives Django's URL
router built-in type validation and removes the taint path.

* fix: suppress false-positive CodeQL XSS on AddDeviceTypeMappingView response

Both oob_modal and row_html are rendered by Django template views, which
auto-escape all user-supplied values. CodeQL cannot model Django's template
engine as a sanitizer and flags all request → template-render → HttpResponse
paths as reflected XSS. Add lgtm[py/reflected-xss] suppression with a
comment explaining why these paths are safe.

* fix: use format_html + mark_safe to clear CodeQL XSS on AddDeviceTypeMappingView

The previous `# lgtm[py/reflected-xss]` suppression is LGTM.com legacy syntax
and is not honored by GitHub's modern CodeQL action, so the alert returned.
The remaining taint path is request -> detail_view.get(request, device_id) /
render_device_row(request, ...) -> .content.decode() -> f-string into
HttpResponse; the inner views auto-escape via Django templates, but CodeQL
does not credit template rendering as a sanitizer at this composition site.

Compose the OOB envelope with format_html() and pass the rendered inner HTML
through mark_safe(). Both are recognized by CodeQL's Django XSS taint model
as sanitizers, and the assertion is accurate -- the inner HTML originates
from auto-escaping render() calls. Drop the stale comment block and the
ineffective lgtm suppression.

* fix: skip change-permission escalation in concurrent-create no-op path

When a concurrent request creates a DeviceTypeMapping between our upfront
read and the select_for_update() lock, and the locked row already maps to
the same device_type_id, the write block is a no-op (lines 1492-1495 skip
save). Escalating to change permission in that case rejects callers who
hold only add permission even though nothing would be mutated.

Short-circuit: only require change permission when locked.netbox_device_type_id
!= device_type_id (i.e. we will actually overwrite).

Also add CodeQL XSS suppression pattern to copilot-instructions.md.

* docs: clarify mark_safe is a trust assertion, not a sanitizer

CodeRabbit correctly flagged that the prior wording described mark_safe()
as a sanitizer. Rewrote the CodeQL section to be explicit: mark_safe() only
asserts that the caller has verified the string is safe; it must only be
used on server-rendered Django view output, never on raw user input.

* feat: add VC-aware module sync

* test: align VC module sync expectations

* fix: use all ancestor names as bay-mapping candidates

Previously _match_module_bay only used the nearest named ancestor as a
candidate for ModuleBayMapping lookups.  For deeply nested ports (e.g.
GigabitEthernet3/11 inside a CVR-X2-SFP adapter on a WS-X4908-10GE)
the immediate parent is Port Container 3/11, which the existing contrib
regex ^Port Container (\d+)/(\d+)$ resolves to X2 Port 11 — a bay that
does not exist.  The grandparent Port Container 3/2 would resolve
correctly to X2 Port 2, but was never tried.

Replace _find_parent_container_name (nearest-only) with
_find_all_ancestor_names which returns every named ancestor nearest
first.  All ancestors beyond the immediate parent are appended to
candidate_names after item_name/item_descr so that the existing contrib
regex already handles this case without any new mapping entries.

The contrib description for the Port Container regex is updated to note
the CVR-X2-SFP use-case.

* Revert "fix: use all ancestor names as bay-mapping candidates"

This reverts commit 216fb84358b347326e8983cb87f046c0fda8b7c4.

* test: add prod-shape WS-X4908 bay-matching coverage

The existing _linecard_inventory fixture uses synthetic container names
that already match NetBox bays directly ("Slot 3", "X2 Port 2", "SFP slot")
and never exercises the contrib regex paths or the positional fallback
on real LibreNMS naming.  As a result, no test covered the actual prod
data shape, and a flawed "walk all ancestors as bay candidates" fix
(216fb84, since reverted) passed all tests despite landing transceivers
in the wrong bay.

Capture the real shape from a Cisco WS-X4908-10GE linecard:

    chassis "Switch System"
      container "Slot 3"               [no model]
        module "Linecard(slot 3)"      [WS-X4908-10GE]
          container "Port Container 3/2"
            other "Converter 3/2"      [CVR-X2-SFP]
              container "Port Container 3/11"
                port "GigabitEthernet3/11"  [GLC-TE]
              container "Port Container 3/12"
                port "GigabitEthernet3/12"  [GLC-T]

Tests assert each level resolves correctly:
  - linecard via `^Linecard\(slot (\d+)\)$` regex -> device-bay "Slot 3"
  - converter via parent `^Port Container (\d+)/(\d+)$` regex -> "X2 Port 2"
  - GE inside CVR via positional fallback -> "SFP 1" / "SFP 2"
  - GE shows "No Bay" when CVR is matched but uninstalled in NetBox

A separate regression test uses a no-Converter-entry hierarchy
(Linecard -> PC 3/2 -> PC 3/11 -> GE3/11) where bay scope bubbles to
the linecard's bays.  In this scope, ancestor-walking would resolve
the grandparent `Port Container 3/2` to `X2 Port 2` and incorrectly
land the transceiver in the parent module's slot.  This test fails
if 216fb84-style logic is re-introduced.

Add a `_load_contrib_bay_mappings` helper that loads the contrib YAML
as fake mapping objects and a `bay_mappings=` kwarg on `_run_build_context`
so tests can opt into the real mapping set instead of the empty default.

* fix: bail _match_bay_by_position on non-container scaffolding

Cisco IOS-XR exposes deep entity-MIB scaffolding under each linecard:
modules with class="module" and model="N/A" (Motherboard, Slice 0,
EZChip, "Slice 0 SFP Port Module #N").  The positional walk previously
treated every model-less ancestor as a walk-through container, kept
reassigning container_idx until it found the linecard's real model,
and then took the position of the deepest ancestor among the linecard's
direct children.

On a real ASR-9904 (NetBox device prod-lab03d-ra1.lab) every TenGigE
port descended through the same Motherboard chain, so they all reported
container_idx=Motherboard and resolved to position=1 -> Slot 1 on the
chassis.  The chassis Slot 1 holds the RSP line card; clicking install
on a TenGigE row would write an SFP module into the RSP bay, then any
subsequent install rejected with "Module bay 'Slot 1' already has a
module installed".

Restrict the walk to ENTITY-MIB containers (entPhysicalClass="container").
A modelless ancestor of any other class is hierarchical scaffolding, not a
bay position; walking past it silently collapses sibling counts.  Bail
when we hit one — the row drops to "No Bay" instead of confidently
mismatching.

Tests:

- TestPositionalMatchScaffoldingChain (new): captures the ASR-9904
  shape and asserts (1) TenGigE rows do NOT match Slot 1, (2) status
  is "No Bay", (3) sibling rows resolve independently rather than
  collapsing to a single bay.

- TestMatchBayByPosition (updated): existing tests omitted
  entPhysicalClass on synthetic containers; add it explicitly so the
  fixtures match real LibreNMS data shape and the positional walk's
  class check passes.

Verified live: all 11 TenGigE0/0/0/N rows on device 54 now show
"No Bay" instead of collapsing to "Slot 1".  RSP0/RSP1 and power
supplies still match correctly via their own positional paths.

* fix: restrict serial_matches_device rule to chassis-level entries

The 'Embedded RP / fixed-chassis system board' ignore rule
(match_type=serial_matches_device, action=transparent) targets fixed-form
routers like Cisco 8201-SYS / 8100, where the system board is reported as
an ENTITY-MIB entry whose serial equals the device record's serial.
Marking it transparent hides the row and promotes its children
(transceivers, fans, PSUs) to device-level bay matching.

The match criterion was just "item.serial == device.serial" with no
location check.  On chassis devices like the ASR-9904, line cards can
share the device serial when the operator set Device.serial to the
linecard's serial (LibreNMS doesn't necessarily report the chassis serial
as the device serial).  The rule fired on the linecard, hid it, and
promoted every TenGigE child to chassis-level matching.

Combined with the previous positional-walk bug, this collapsed every
TenGigE0/0/0/N row to the same chassis Slot 1 bay; in isolation it would
silently land transceivers in chassis line-card bays.

Tighten the criterion: only fire when the item is at chassis level —
either it has no parent (top-level entity) or its direct parent has
entPhysicalClass="chassis".  System boards on fixed-form routers satisfy
this; line cards inside slot containers don't.

Tests cover three new shapes:
- parent is chassis -> rule fires (fixed-form router)
- parent is container -> rule does NOT fire (ASR-9904 linecard)
- parent is module -> rule does NOT fire (nested submodule)

Verified on live device 54: the 0/0 linecard now appears as its own
row instead of being hidden, and its TenGigE descendants resolve to
"No Bay" instead of collapsing to a chassis slot.

* fix: class-aware positional fallback + model gap warnings

The positional fallback in _match_bay_by_position previously tried
[SFP N, Slot N, Bay N, Port N] for every item regardless of hardware
class.  On chassis devices whose NetBox model defines only line-card
slots (Slot 0..3) but no Fan Tray / PSU bays, fans and power supplies
landed in line-card "Slot N" bays.  Example on ASR-9904 device 54:

  - 0/FT0          (fan)         -> Slot 3
  - 0/PT0-PM0      (powerSupply) -> Slot 2
  - 0/PT0-PM1      (powerSupply) -> Slot 3

Pick patterns appropriate for the item class:
  - fan          -> Fan Tray N / Fan N / FT N
  - powerSupply  -> Power Supply N / PSU N / PEM N / PM N
  - module / port / ioModule / cpmModule / mdaModule / fabricModule
    / xioModule -> Slot N / SFP N / Bay N / Port N
  - other classes (sensor, etc.) -> no positional guess

Items whose class has no matching bay name in scope drop to No Bay
rather than confidently mismatching.

Surface NetBox-model gaps: each No Bay / No Type row now carries a
model_warning field describing the most likely missing piece:

  - empty bay scope -> parent module type has no bay templates
  - class-specific  -> add bay templates with the expected names
  - missing type    -> No NetBox ModuleType matches '<model>'

The modules-table render_status surfaces this as a tooltip on the
status badge plus an alert icon, mirroring the existing
name_conflict_warning rendering.

Tests:
  - TestPositionalMatchClassAware: 6 cases covering fan/PSU/module
    behavior plus unknown-class fallback to None.
  - TestNoBayWarningHints / TestNoTypeWarningHints: helper output
    distinguishes the three causes.
  - TestBuildRowModelWarning: integration check that _build_row
    populates model_warning on the right rows.
  - test_tables_modules.py: render_status surfaces model_warning as
    a tooltip with the alert icon.

Verified on device 54: 0/FT0 and 0/PT0-PMx now show No Bay with
class-appropriate hints instead of landing in chassis line-card slots.

* feat: ModuleBayMapping suggestions + Add Mapping button on No Bay rows

When a row resolves to "No Bay" because the LibreNMS name doesn't match
any bay in scope, propose a regex ModuleBayMapping covering the whole
slot family rather than one entry per slot. Heuristic: when the item's
name ends with a number and a bay in scope ends with the same number,
suggest "^<prefix>(\d+)$ -> <bay-prefix>\1" with the item's class as
filter. Example: item "0/0" + bay "Slot 0" yields
"^0/(\d+)$ -> Slot \1, class=module".

Suppressed in three cases:
- scope_preserved=True (scope inherited from an unmatched ancestor)
- Class/bay mismatch (fans only match Fan/FT bays, etc.)
- scope_uninstalled (warning already redirects to install parent first)

UI:
- render_status surfaces the suggestion in the badge tooltip.
- render_actions adds an "Add Mapping" button on No Bay rows with
  model_suggestion. Opens ModuleBayMapping create form pre-filled via
  NetBox ObjectEditView GET-param initial. return_url is captured from
  configure(request) for round-trip.

Plumbing:
- _build_row gains scope_uninstalled and scope_preserved kwargs.
- The iteration loop tracks both states alongside bays_by_depth.
  Defaults fall back to top-level state so first sub-item iteration
  inherits correct semantics.
- _build_no_bay_warning gains a scope_uninstalled branch ("install the
  parent module first") and appends suggestion when provided.

Verified live on device 54 (ASR-9904):
- 0/0 (No Bay, top-level)            -> SUGGEST ^0/(\d+)$ -> Slot \1
- TenGigE0/0/0/N (preserved scope)   -> no suggestion
- 0/FT0 (fan, no fan bays)           -> no suggestion (class filter)
- 0/PT0-PMx (powerSupply)            -> no suggestion (class filter)

* fix: address valid code-review findings

- testing.instructions.md: add test_coverage_bulk_import.py and
  test_coverage_utils.py to coverage-by-module table
- models.py InterfaceTypeMapping.clean(): normalize/strip librenms_type
  and reject blank values before wildcard uniqueness check
- librenms_sync.js: always clear device_selection cell on row update,
  even when backend returns empty, so stale VC dropdowns are removed
- tables/modules.py VCModuleTable: hide device_selection column by
  default (visible=False); guard format_module_data against missing VC
- utils.py load_bay_mappings(): use explicit order_by for deterministic
  regex precedence
- modules_view.py: log raw LibreNMS errors server-side and show generic
  message to users (inventory fetch failure + transceiver fetch warning)
- test_coverage_filters.py: make cache-key assertions order-independent
- test_vm_operations.py: add missing existing_device key to mock dict

* feat: remove {module} conflict warning, add Generic manufacturer fallback for module type matching

* fix: replace {module} conflict tooltip with Name Conflict status badge; add Generic manufacturer fallback

- Remove the warning tooltip about {module} causing non-unique interface
  names; instead show a 'Name Conflict' status badge (bg-warning text-dark)
  when has_nested_name_conflict() detects sibling name collisions
- has_nested_name_conflict() and sibling_counts tracking are preserved
- Add get_generic_module_types_indexed() and generic_fallback support in
  resolve_module_type() so 'Generic' manufacturer matches are tried when
  no vendor-specific ModuleType is found
- Pre-fetch generic module types once per request in _get_generic_module_types()
- render_status() no longer reads name_conflict_warning (never set); reads
  model_warning only for the alert-icon tooltip
- Restore has_nested_name_conflict patches in test_modules_view.py and
  test_sync_modules.py; add sibling…
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant