Skip to content

Inventory - #4

Closed
marcinpsk wants to merge 40 commits into
developfrom
inventory
Closed

Inventory#4
marcinpsk wants to merge 40 commits into
developfrom
inventory

Conversation

@marcinpsk

@marcinpsk marcinpsk commented Feb 24, 2026

Copy link
Copy Markdown
Owner

Summary by CodeRabbit

  • New Features

    • CRUD UIs, REST endpoints and import tools for Device/Module Type mappings, Module Bay mappings, and Normalization Rules; Module Sync tab with module table, transceiver support, module install/branch, per-server LibreNMS ID handling, conflict/details modal, device-name sync, and naming preferences.
  • Bug Fixes

    • UI typo fixed; improved modal lifecycle/HTMX handling and sizing; added module countdown timer and other UI stability refinements.
  • Documentation

    • Added example mapping, normalization, and import/configuration docs; expanded custom-field guidance.
  • Tests

    • Large expansion of unit, integration, and end-to-end tests covering imports, conflict resolution, module install, VM/device workflows.

@coderabbitai

coderabbitai Bot commented Feb 24, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Adds mapping and normalization models, CRUD APIs, module inventory sync/install flows, a refactored import_utils package (split into focused modules), per-server librenms_id helpers and migration, naming-preference propagation, many view/template/form/table/JS updates, and extensive unit and e2e tests.

Changes

Cohort / File(s) Summary
Models & Migrations
netbox_librenms_plugin/models.py, netbox_librenms_plugin/migrations/...
0009_add_devicetypemapping.py, 0010_add_moduletypemapping.py, 0011_modulebaymapping.py, 0012_add_is_regex_to_modulebaymapping.py, 0013_normalizationrule.py
Add DeviceTypeMapping, ModuleTypeMapping, ModuleBayMapping, NormalizationRule models with validation, tags, ordering and migrations (including SeparateDatabaseAndState for normalization rules).
Import refactor & orchestration
netbox_librenms_plugin/import_utils/...
bulk_import.py, device_operations.py, filters.py, cache.py, virtual_chassis.py, vm_operations.py, permissions.py, __init__.py
Remove monolithic import_utils.py; introduce package modules for device listing/filters, deterministic cache keys, device validation/import ops, virtual-chassis detection/creation, bulk orchestration, VM import, and permission helpers.
Core utilities
netbox_librenms_plugin/utils.py, netbox_librenms_plugin/librenms_api.py
Add per-server get/set/migrate helpers for librenms_id, find_by_librenms_id, normalization and module-path helpers, hardware→DeviceType mapping using DeviceTypeMapping; add LibreNMSAPI.get_device_transceivers().
API, Serializers & Filters
netbox_librenms_plugin/api/serializers.py, api/views.py, api/urls.py, netbox_librenms_plugin/filters.py
Add serializers, viewsets, router registrations and filtersets to expose CRUD API endpoints for new mapping and normalization models.
Forms, Templates & Contrib data
netbox_librenms_plugin/forms.py, navigation.py, templates/..., contrib/*.yaml, docs/*
Add create/import/filter forms and templates for new models, navigation entries, many contrib YAML examples (mappings/rules), and docs updates (custom-field auto-create note, minor text fixes).
Views: Mapping, Sync & Actions
netbox_librenms_plugin/views/mapping_views.py, views/__init__.py, views/base/modules_view.py, views/object_sync/devices.py, views/imports/actions.py, views/sync/*
Add full CRUD + bulk-import views for mappings; implement BaseModuleTableView, InstallModuleView, InstallBranchView; add DeviceConflictActionView, SaveUserPrefView, UpdateDeviceNameView, RemoveServerMappingView; propagate naming-preferences (use_sysname/strip_domain) through jobs and sync flows.
Tables & JS
netbox_librenms_plugin/tables/..., static/netbox_librenms_plugin/js/librenms_import.js, static/.../librenms_sync.js
Add mapping and module tables; device-status now renders HTMX-backed conflict/details button; JS centralizes modal hiding and adds module countdown lifecycle.
Device/module templates & partials
templates/netbox_librenms_plugin/_module_sync_content.html, inc/_module_sync.html, redesigned device_validation_details.html, mapping templates...
New module-sync partials, redesigned device validation/details modal, Modules tab integration and per-device module UI fragments.
Views: cables/interfaces/ip sync adjustments
views/base/cables_view.py, views/sync/interfaces.py, views/base/ip_addresses_view.py
Safer per-request interface name resolution; server_key-aware librenms_id lookups via utils; per-interface transactions and guarded error handling.
Tables: device/module UI
netbox_librenms_plugin/tables/modules.py, .../device_status.py, .../mappings.py, .../interfaces.py
Add LibreNMSModuleTable with install actions; mapping tables; device-status adds HTMX conflict/details actions; interface table reads server-aware librenms_id.
Tests & e2e
netbox_librenms_plugin/tests/*, tests/e2e/*
Large test additions and updates: post_migrate custom-field tests, serial-matching and conflict-action tests, naming-pref tests, mapping-match tests, many unit updates, and a Playwright e2e module-install workflow.
Import utilities removal/relocation
netbox_librenms_plugin/import_utils.py (removed)
Remove legacy monolithic import_utils.py; moved functionality into the new import_utils package modules.
Init & Post-migrate
netbox_librenms_plugin/__init__.py, netbox_librenms_plugin/tests/test_init.py
Register post_migrate handler to ensure/migrate librenms_id CustomField; add tests for handler behavior.
URLs & navigation
netbox_librenms_plugin/urls.py, navigation.py
Add routes and menu items for DeviceTypeMapping, ModuleTypeMapping, ModuleBayMapping, NormalizationRule, device module endpoints, conflict-action endpoints, and device-level update/remove mappings.
Devcontainer & CI
.devcontainer/*, .github/workflows/lint-format.yaml
Minor devcontainer README/script comment/typo edits; CI workflow simplified and updated to Python 3.12 and streamlined Ruff steps.
Minor templates/UX
assorted templates
Modal width increased in import UI, HTMX include attributes extended, and UI text tweaks.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant UI as NetBox UI
    participant View as BaseModuleTableView
    participant API as LibreNMS API
    participant Cache as Django Cache
    participant DB as NetBox DB
    participant Installer as InstallModuleView

    User->>UI: Click "Refresh Modules" or "Install Module"
    UI->>View: HTMX POST /devices/{id}/module-sync or /install-module
    View->>API: GET /api/v0/devices/{id}/inventory
    API-->>View: inventory
    View->>API: GET /api/v0/devices/{id}/transceivers
    API-->>View: transceivers
    View->>Cache: lookup mappings / cached VC data
    Cache-->>View: mapping/cache result
    View->>DB: query ModuleBay / ModuleType and mappings
    DB-->>View: bay/type records
    View->>Installer: validate & request install
    Installer->>DB: transactional create Module(s)
    DB-->>Installer: created entries
    Installer->>View: success result
    View->>User: HTMX swap updates module table
Loading
sequenceDiagram
    participant User
    participant UI as NetBox UI
    participant ValidationView as DeviceValidationDetailsView
    participant Validator as validate_device_for_import
    participant API as LibreNMS API
    participant DB as NetBox DB
    participant ConflictView as DeviceConflictActionView

    User->>UI: Open device validation modal
    UI->>ValidationView: GET details (HTMX)
    ValidationView->>Validator: validate_device_for_import(libre_device)
    Validator->>API: optional VC / inventory requests
    API-->>Validator: VC/inventory
    Validator->>DB: lookup existing device by librenms_id/serial/hostname
    DB-->>Validator: existing device info
    Validator-->>ValidationView: validation payload (issues/warnings)
    ValidationView->>User: render conflict modal with actions
    User->>UI: choose action (link/update/sync)
    UI->>ConflictView: POST conflict-action
    ConflictView->>DB: apply updates (set_librenms_device_id, change fields)
    DB-->>ConflictView: commit
    ConflictView->>ValidationView: refresh validation fragment
    ValidationView->>User: updated modal content
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

Poem

🐰
I hopped through mappings, neat and spry,
I matched each bay beneath the sky,
I nudged IDs, synced names with care,
I dug up bugs and fixed them fair,
A rabbit’s patch — a tidy netbox lair 🥕

🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Description check ⚠️ Warning The pull request description is entirely empty despite a template being available in the repository with required sections. Complete the pull request description using the provided template, including Summary, Motivation/Problem, Scope of Change, Testing approach, Risk Assessment, and Backwards Compatibility sections.
Title check ❓ Inconclusive The title 'Inventory' is vague and generic, using a non-descriptive term that does not clearly convey the main purpose of the changeset. Provide a more specific title describing the key changes, such as 'Add multi-server LibreNMS inventory management' or 'Implement device module sync with multi-server support'.
✅ Passed checks (1 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 82.69% which is sufficient. The required threshold is 80.00%.

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

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch inventory

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.

@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: 26

Caution

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

⚠️ Outside diff range comments (12)
.devcontainer/scripts/start-netbox.sh (2)

20-23: ⚠️ Potential issue | 🟡 Minor

Debug echo leaks into local dev output unconditionally.

The 🐛 Debug: message on line 22 is always printed for non-Codespaces environments, which clutters normal local startup output. Consider removing it or guarding it behind a DEBUG_VERBOSE flag.

🔧 Proposed fix
 else
   ACCESS_URL="http://localhost:8000"
-  echo "🐛 Debug: ACCESS_URL is set to: $ACCESS_URL"
 fi
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.devcontainer/scripts/start-netbox.sh around lines 20 - 23, The debug echo
that prints "🐛 Debug: ACCESS_URL is set to: $ACCESS_URL" runs unconditionally
and clutters normal local startup; either remove that echo or guard it behind a
verbosity flag (e.g., check a DEBUG_VERBOSE or VERBOSE env var) before printing,
so update start-netbox.sh to only print the debug line when the flag is set
(referencing ACCESS_URL and the existing echo statement) and default to silence
for normal runs.

27-40: 🧹 Nitpick | 🔵 Trivial

Orphaned processes are sent SIGKILL without a prior SIGTERM.

Both orphan cleanup blocks (rqworker and runserver) jump straight to pkill -9, skipping a graceful shutdown attempt. This can corrupt in-flight data (e.g. open DB transactions, pending RQ jobs). Consider sending SIGTERM first and only escalating to SIGKILL after a brief wait.

♻️ Proposed approach
-pkill -9 -f "python.*rqworker" 2>/dev/null
-sleep 1
+pkill -TERM -f "python.*rqworker" 2>/dev/null
+sleep 2
+pkill -9 -f "python.*rqworker" 2>/dev/null || true

Apply the same pattern for runserver.

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

In @.devcontainer/scripts/start-netbox.sh around lines 27 - 40, The orphan
cleanup blocks currently kill processes immediately with pkill -9; modify both
the rqworker and runserver sections (ORPHAN_RQ_PIDS and ORPHAN_NETBOX_PIDS) to
first send a graceful SIGTERM (e.g. pkill -15 -f "python.*rqworker" and pkill
-15 -f "python.*runserver.*8000") when pids are found, sleep briefly (e.g.
1-3s), then check whether the processes remain and only then escalate to pkill
-9; ensure you reuse the existing pgrep checks and messages so the script still
reports “Found orphaned …, killing…” but performs the two-step TERM->KILL
escalation.
.devcontainer/scripts/setup.sh (2)

255-266: ⚠️ Potential issue | 🟡 Minor

Superuser password logged in plaintext.

Line 263 prints Created superuser: {username}/{password} to stdout. In GitHub Codespaces or CI environments these setup logs can be captured and retained. The password confirmation can be omitted or masked.

🔧 Proposed fix
-    print(f'Created superuser: {username}/{password}')
+    print(f'Created superuser: {username} (password set)')
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.devcontainer/scripts/setup.sh around lines 255 - 266, The script prints the
newly created superuser password in the python manage.py shell block (the print
call that outputs Created superuser: {username}/{password}), which leaks
secrets; change the output to omit or mask the password (e.g., only print the
username or "password set" message) inside that python block so the
create_superuser call still confirms success but never logs the plaintext
password; update the print statements referenced in the inline Python executed
by python manage.py shell accordingly.

251-252: ⚠️ Potential issue | 🟠 Major

Migration errors silently swallowed.

The || true on line 252, combined with grep -E (...) filtering, means any manage.py migrate failure (e.g. DB unreachable, failed migration) exits 0 without surfacing the error. This can mask a broken environment silently.

Consider removing || true or at minimum capturing the exit code separately so a critical migration failure still causes the setup to fail visibly.

🔧 Suggested approach
-python manage.py migrate 2>&1 | grep -E "(Operations to perform|Running migrations|Apply all migrations|No migrations to apply|\s+Applying|\s+OK)" || true
+python manage.py migrate 2>&1 | tee /tmp/migrate.log | grep -E "(Operations to perform|Running migrations|Apply all migrations|No migrations to apply|\s+Applying|\s+OK)" || true
+if grep -qiE "(error|traceback|exception)" /tmp/migrate.log 2>/dev/null; then
+  echo "❌ Migration errors detected — check /tmp/migrate.log"; exit 1
+fi
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.devcontainer/scripts/setup.sh around lines 251 - 252, The migrate step
currently pipes manage.py migrate through grep and appends "|| true", which
masks any failure; update the setup to run "python manage.py migrate" without
the trailing "|| true" and without filtering its stderr through grep so real
errors surface, or alternatively capture the migrate exit code (run manage.py
migrate, save $? into a variable) and if non-zero emit the full migrate
output/error via echo/process logger and exit with that non-zero code; locate
the migrate invocation in the setup script (the line that calls python manage.py
migrate) and remove the silent-fail pattern so critical migration failures cause
the setup to fail visibly.
.devcontainer/scripts/welcome.sh (1)

47-47: ⚠️ Potential issue | 🟡 Minor

Typo in user-facing output: "into you browser".

🔧 Proposed fix
-  echo "   NetBox will be available at: http://localhost:8000 (paste into you browser)"
+  echo "   NetBox will be available at: http://localhost:8000 (paste into your browser)"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.devcontainer/scripts/welcome.sh at line 47, Fix the typo in the user-facing
echo string: update the echo command that prints "NetBox will be available at:
http://localhost:8000 (paste into you browser)" to use "your" instead of "you"
so the message reads "(paste into your browser)"; locate the echo statement in
.devcontainer/scripts/welcome.sh and change the string accordingly.
.devcontainer/scripts/diagnose.sh (1)

39-49: ⚠️ Potential issue | 🟡 Minor

Unquoted $PID in kill -0 check.

If the PID file is empty or malformed, kill -0 $PID without quotes expands to kill -0 (no argument), emitting a usage error before 2>/dev/null silences it. Quote the variable for correctness and clarity.

🔧 Proposed fix
-  if kill -0 $PID 2>/dev/null; then
+  if kill -0 "$PID" 2>/dev/null; then
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.devcontainer/scripts/diagnose.sh around lines 39 - 49, The PID check in the
diagnose.sh snippet uses an unquoted variable (PID) in the kill command which
can produce a usage error if the PID file is empty or malformed; update the kill
test to use the quoted variable (use "PID" in the kill invocation) and also
guard against empty content by ensuring PID is non-empty before calling kill
(e.g., test -n "$PID" or treat empty as not running) so the block around PID,
kill -0, and the subsequent echo messages (references: variable PID and the kill
-0 check) handles empty/malformed PID files safely.
.github/workflows/test.yaml (1)

52-56: ⚠️ Potential issue | 🟠 Major

Align the workflow's scope with its stated purpose or update the workflow title.

The workflow is titled "Test with all supported NetBox versions," but it only checks out the main branch—no matrix or conditional logic tests multiple versions. NetBox maintains active releases (v4.4.5 through v4.5.3 and beyond), so either add a version matrix strategy to test against supported releases, or update the workflow title to reflect that it tests the development branch only.

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

In @.github/workflows/test.yaml around lines 52 - 56, The workflow title and its
checkout step are inconsistent: it currently says "Test with all supported
NetBox versions" but the checkout in the job uses actions/checkout@v4 with
repository: "netbox-community/netbox" and ref: main, so update the workflow to
either add a matrix strategy (e.g., a job-level matrix over NetBox refs/tags for
the supported releases) and use the matrix value for the checkout ref instead of
the hardcoded ref: main, or change the workflow name/title to indicate it only
tests the main branch; locate the job that contains uses: actions/checkout@v4
and ref: main and modify it accordingly.
netbox_librenms_plugin/views/sync/cables.py (2)

90-103: ⚠️ Potential issue | 🟠 Major

netbox_remote_device_id is gated as required but never consumed — valid cables are silently blocked.

verify_cable_creation_requirements requires netbox_remote_device_id (line 95), but handle_cable_creation only fetches netbox_local_interface_id and netbox_remote_interface_id (lines 106-107). netbox_remote_device_id is never read anywhere in the class.

When cached link data has both interface IDs but no netbox_remote_device_id, the prerequisite check returns False, and the cable is never created. The resulting "invalid" status also surfaces the misleading message "No LibreNMS link data found for interfaces" even though the link data was found and both interface IDs are available.

🐛 Proposed fix
     def verify_cable_creation_requirements(self, link_data):
         """Return True if all required NetBox IDs are present in link data."""
         required_fields = [
             "netbox_local_interface_id",
-            "netbox_remote_device_id",
             "netbox_remote_interface_id",
         ]

         return all(link_data.get(field) for field in required_fields)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@netbox_librenms_plugin/views/sync/cables.py` around lines 90 - 103, The
prereq check in verify_cable_creation_requirements wrongly requires
"netbox_remote_device_id" which is never consumed by handle_cable_creation,
causing valid links (with only interface IDs) to be rejected; remove
"netbox_remote_device_id" from the required_fields list in
verify_cable_creation_requirements so it only requires
"netbox_local_interface_id" and "netbox_remote_interface_id", and keep
handle_cable_creation as-is (or, alternatively, if remote device id is actually
needed, consume it inside handle_cable_creation where local/remote interface IDs
are read).

50-59: ⚠️ Potential issue | 🔴 Critical

DatabaseError swallowed inside transaction.atomic() will trigger TransactionManagementError on subsequent iterations.

Cable.objects.create() can raise IntegrityError (e.g., a duplicate-termination constraint). The broad except Exception in create_cable catches it inside the transaction.atomic() block. If you catch and handle exceptions inside an atomic block, you may hide from Django the fact that a problem has happened. This can result in unexpected behavior — mostly a concern for DatabaseError and its subclasses such as IntegrityError. After such an error, the transaction is broken and Django will perform a rollback at the end of the atomic block. If you attempt to run database queries before the rollback happens, Django will raise a TransactionManagementError.

In practice: after the except swallows the IntegrityError, process_interface_sync continues to the next iteration and opens a new with transaction.atomic() savepoint. Any DB query in that block raises TransactionManagementError, crashing the entire post handler for all remaining interfaces — the opposite of the per-interface isolation the docstring promises.

The fix is to catch database errors around the atomic block, not inside it:

🐛 Proposed fix
     def create_cable(self, local_interface, remote_interface, request):
         """Create a cable between local and remote interfaces.

         Returns:
             True on success, False on failure.
         """
-        try:
-            Cable.objects.create(
-                a_terminations=[local_interface],
-                b_terminations=[remote_interface],
-                status="connected",
-            )
-            return True
-        except Exception as exc:  # pragma: no cover - protects UX
-            messages.error(request, f"Failed to create cable: {str(exc)}")
-            return False
+        Cable.objects.create(
+            a_terminations=[local_interface],
+            b_terminations=[remote_interface],
+            status="connected",
+        )
+        return True

Move the error handling outside the atomic scope in process_interface_sync:

         for interface in selected_interfaces:
-            with transaction.atomic():
-                result = self.process_single_interface(interface, cached_links)
-            results[result["status"]].append(result.get("interface", ""))
+            try:
+                with transaction.atomic():
+                    result = self.process_single_interface(interface, cached_links)
+            except Exception as exc:  # pragma: no cover - protects UX
+                iface_key = interface.get("interface", "")
+                messages.error(self.request, f"Failed to create cable: {str(exc)}")
+                result = {"status": "invalid", "interface": iface_key}
+            results[result["status"]].append(result.get("interface", ""))

Also applies to: 127-130

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

In `@netbox_librenms_plugin/views/sync/cables.py` around lines 50 - 59, The
current create_cable/process_interface_sync flow catches all Exceptions
(including IntegrityError/DatabaseError) inside a transaction.atomic block which
can break the transaction and cause TransactionManagementError on subsequent DB
work; refactor so that database errors are not swallowed inside the atomic
scope: remove the broad try/except that surrounds Cable.objects.create() within
the atomic block in create_cable (or process_interface_sync), let DB exceptions
propagate so the atomic block rolls back, and instead catch
DatabaseError/IntegrityError outside the transaction.atomic in the calling code
(process_interface_sync) to record a user-facing message (messages.error) and
continue per-interface processing; reference the functions create_cable and
process_interface_sync to locate the changes and ensure only non-database
exceptions (if any) are handled inside the atomic block.
netbox_librenms_plugin/views/sync/interfaces.py (1)

158-185: 🛠️ Refactor suggestion | 🟠 Major

Duplicate enabled assignment — remove from sync_interface.

update_interface_attributes (lines 252–258) now sets and saves interface.enabled. The identical block at lines 166–175 in sync_interface re-sets the same field after update_interface_attributes returns, producing a redundant write. Remove the block from sync_interface.

♻️ Proposed fix
         self.update_interface_attributes(
             interface,
             librenms_interface,
             netbox_type,
             exclude_columns,
             interface_name_field,
         )

-        if "enabled" not in exclude_columns:
-            interface.enabled = (
-                True
-                if librenms_interface.get("ifAdminStatus") is None
-                else (
-                    librenms_interface["ifAdminStatus"].lower() == "up"
-                    if isinstance(librenms_interface["ifAdminStatus"], str)
-                    else bool(librenms_interface["ifAdminStatus"])
-                )
-            )
-
         # Sync VLANs if not excluded
🤖 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 158 - 185, The
block in sync_interface that re-assigns interface.enabled is redundant because
update_interface_attributes already sets and saves interface.enabled; remove the
entire conditional that checks "enabled" in exclude_columns and the nested
assignment to interface.enabled from sync_interface, leaving the VLAN sync and
save logic (vlan_synced/_sync_interface_vlans/_update_interface_vlan_assignment)
intact so no extra save occurs.
netbox_librenms_plugin/tests/test_import_utils.py (1)

301-309: ⚠️ Potential issue | 🟠 Major

Patch import_utils symbols directly to ensure mocks are used.

These decorators patch virtualization.models.VirtualMachine, but import_utils imports and uses the symbol; the mock won’t apply unless you patch netbox_librenms_plugin.import_utils.VirtualMachine (and similarly for Cluster/Site/Rack). This can leak real model access.

✅ Suggested fix (apply similarly elsewhere)
-    `@patch`("virtualization.models.VirtualMachine")
+    `@patch`("netbox_librenms_plugin.import_utils.VirtualMachine")

Based on learnings “Patch deferred/inline imports at their source module (e.g., netbox_librenms_plugin.import_utils.process_device_filters), not the consuming module”.

🤖 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 301 - 309,
The test patches target external modules (e.g.,
virtualization.models.VirtualMachine) but import_utils uses its own imported
symbols, so replace those decorators to patch the symbols as they are imported
into import_utils (patch netbox_librenms_plugin.import_utils.VirtualMachine,
.Cluster, .Rack, .Site, .Device, .DeviceRole, .find_matching_site,
.find_matching_platform, .match_librenms_hardware_to_device_type) so the mocks
are applied where import_utils actually references them; update any similar
tests to patch the symbols on netbox_librenms_plugin.import_utils rather than
their original source modules.
netbox_librenms_plugin/views/__init__.py (1)

5-90: ⚠️ Potential issue | 🟠 Major

Fix F401 lint failures for re-export imports.

Pyflakes treats these as unused; the lint job is already failing. Add # noqa: F401 to the import statements or define __all__ to mark intended re-exports.

🧹 Example fix (apply to all re-export imports)
-from .base.modules_view import BulkInstallModulesView, InstallBranchView, InstallModuleView
+from .base.modules_view import BulkInstallModulesView, InstallBranchView, InstallModuleView  # noqa: F401
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@netbox_librenms_plugin/views/__init__.py` around lines 5 - 90, Pyflakes
reports F401 unused-imports because this package __init__.py re-exports many
view classes; fix by marking those imports as intentional exports—either add “#
noqa: F401” to each re-export import line (e.g., lines importing
BaseCableTableView, SingleCableVerifyView, LibreNMSImportView,
DeviceCableTableView, SyncCablesView, LibreNMSSettingsView, etc.) or define an
explicit __all__ tuple listing the exported symbols (include names like
BaseInterfaceTableView, BaseIPAddressTableView, BulkInstallModulesView,
DeviceTypeMappingListView, NormalizationRuleView, DeviceLibreNMSSyncView,
VMIPAddressTableView, TestLibreNMSConnectionView, etc.) so linters recognize
them as used.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In @.github/workflows/test.yaml:
- Around line 42-48: The workflow currently pins GitHub Actions to version tags
"actions/checkout@v4" and "actions/setup-python@v5"; replace those tag
references with the corresponding full commit SHAs (e.g.,
actions/checkout@<full-commit-sha> and actions/setup-python@<full-commit-sha>)
to make CI reproducible and immutable, locating and changing the two "uses:"
lines in the workflow to the verified SHAs from the respective action
repositories before committing.

In `@netbox_librenms_plugin/__init__.py`:
- Around line 117-127: Hoist the duplicate import logging by moving a single
import logging to the top of the module (or at the start of the try block) and
remove the two inline imports; update the existing calls
logging.getLogger("netbox_librenms_plugin").info(...) and
logging.getLogger("netbox_librenms_plugin").exception(...) to use that single
import so there is no repeated import in the try/except branches.

In `@netbox_librenms_plugin/filters.py`:
- Around line 46-53: The filter set relies on django-filters' implicit PK filter
for the ForeignKey manufacturer, causing inconsistency with the form; explicitly
declare a ModelChoiceFilter named manufacturer on NormalizationRuleFilterSet
that points to the NormalizationRule.manufacturer field, uses the Manufacturer
queryset, and sets the filter's field_class to DynamicModelChoiceField (or
otherwise matches the DynamicModelChoiceField used in
NormalizationRuleFilterForm); also import Manufacturer and
DynamicModelChoiceField and remove reliance on implicit generation so the filter
behavior is explicit and consistent with the form.

In `@netbox_librenms_plugin/forms.py`:
- Around line 72-96: The cache is global so switching servers returns wrong
poller-groups; scope the cache key by the active LibreNMS server. Instantiate
LibreNMSAPI (LibreNMSAPI) and derive a server-specific identifier from the
client (e.g. an active_server, server_name, or base_url property on the api
instance) and incorporate it into cache_key instead of the fixed
"librenms_poller_group_choices"; use cache.get(cache_key) and
cache.set(cache_key, choices, timeout=api.cache_timeout) as before so cached
results are server-scoped and still honor api.cache_timeout and the
get_poller_groups flow.

In `@netbox_librenms_plugin/import_utils.py`:
- Around line 825-869: This code directly queries
custom_field_data__librenms_id; instead call the plugin helper
LibreNMSAPI.get_librenms_id to locate a matching Device/VM. Replace
Device.objects.filter(custom_field_data__librenms_id=int(librenms_id)).first()
with a lookup that uses LibreNMSAPI.get_librenms_id(librenms_id) (or a shared
accessor that delegates to that method) to return the mapped object, then
proceed to set result["existing_device"], result["existing_match_type"], and the
serial/name checks as before; ensure you handle non-int librenms_id exceptions
the same way and apply the same refactor for any VM lookups in this module.
- Around line 2231-2311: When cached existing_device is found deleted in
_refresh_existing_device, the code currently sets validation["can_import"]=True,
computes VM readiness without checking cluster, and replaces
validation["device_role"] losing available_roles; instead, recompute can_import
from validation.get("issues") (e.g., True only if no blocking issues), compute
is_ready using cluster for VMs (validation.get("cluster",{}).get("found")) and
the usual device_type/role/site checks for devices, and update device_role
in-place (merge or set "found" and "role" fields without dropping
validation["device_role"].get("available_roles")). Ensure these changes occur in
the branch that handles refreshed is None (the block that currently sets
existing_device=None).

In `@netbox_librenms_plugin/migrations/0013_normalizationrule.py`:
- Around line 15-22: The helper function table_exists in the migration module is
defined but never used; remove the unused function definition (table_exists)
from the migration file to eliminate dead code, or alternatively replace its
usage placeholder by actually calling table_exists wherever a migration needs to
check for an existing table (e.g., before creating/dropping tables inside
functions like forwards/backwards); adjust any imports or tests accordingly so
there are no references to table_exists after its removal or ensure calls pass
the correct table name and database connection if you choose to keep it.

In
`@netbox_librenms_plugin/templates/netbox_librenms_plugin/_module_sync_content.html`:
- Around line 1-31: Move the module sync template into the HTMX fragments folder
and update rendering to reference it: relocate
templates/netbox_librenms_plugin/_module_sync_content.html to
templates/netbox_librenms_plugin/htmx/_module_sync_content.html, then update the
view that returns this partial (modules_view.py POST handler that renders the
fragment) to render "netbox_librenms_plugin/htmx/_module_sync_content.html"
instead of the old path; ensure any template includes or relative paths inside
the moved file still resolve (adjust include paths in the moved template if
needed) so the HTMX swap into `#module-sync-content` continues to work.

In `@netbox_librenms_plugin/templates/netbox_librenms_plugin/_module_sync.html`:
- Around line 1-27: The reusable include currently referenced as '{% include
'netbox_librenms_plugin/_module_sync_content.html' %}' should be moved into the
inc/ subdirectory and the include reference updated accordingly; relocate the
file to templates/netbox_librenms_plugin/inc/_module_sync_content.html and
change the include in _module_sync.html to reference
'netbox_librenms_plugin/inc/_module_sync_content.html' so the template follows
the project's reusable-include convention.

In
`@netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_validation_details.html`:
- Around line 23-24: Replace Bootstrap dismissal attributes on the modal close
buttons with the HTMX-targeted close behavior: remove data-bs-dismiss (and any
data-bs-toggle or duplicate modal IDs) and make the close button target the
`#htmx-modal-content` wrapper used by the HTMX/Tabler modal JavaScript so the
wrapper is toggled by the existing librenms_import.html script; locate the close
buttons in device_validation_details.html (the <button class="btn-close">
elements) and change their attributes to target `#htmx-modal-content` instead of
relying on Bootstrap dismissal.

In
`@netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html`:
- Around line 575-579: Move the reusable template partial _module_sync.html into
the inc/ subdirectory under templates/netbox_librenms_plugin, then update the
include in librenms_sync_base.html so the include refers to
'netbox_librenms_plugin/inc/_module_sync.html' instead of the current path;
ensure the filename and leading underscore remain the same and test the template
rendering to confirm the new include path is resolved.
- Around line 252-263: The template shows a Sync button whenever sysName is
truthy even if it's the placeholder "-"; update the conditional around the form
(and the elif block) to only display the sync form when sysName is not the
placeholder (e.g., sysName != "-" and sysName != object.name). Specifically
change the template logic that checks sysName and object.name for the form that
posts to the 'plugins:netbox_librenms_plugin:update_device_name' action so it
gates submission on a non-placeholder sysName value.

In `@netbox_librenms_plugin/tests/test_init.py`:
- Around line 43-63: The test only verifies logging.getLogger was called but not
that an info-level message was emitted; update the assertion to check the logger
instance's info method was called like in test_no_log_when_field_already_exists:
use mock_get_logger.return_value.info.assert_called_once() (or
assert_called_once_with(<expected message>) if you want to validate the exact
message) after invoking _ensure_librenms_id_custom_field so the test confirms an
info log was emitted by the logger instance.

In `@netbox_librenms_plugin/urls.py`:
- Around line 31-34: Remove the unused import BulkInstallModulesView from the
import list (where BulkInstallModulesView, DeviceVLANTableView,
InstallBranchView, InstallModuleView are imported) because it is not referenced
in urlpatterns; update the import line to exclude BulkInstallModulesView so the
linter error F401 is resolved.

In `@netbox_librenms_plugin/utils.py`:
- Around line 544-559: Normalization can crash when a NormalizationRule has an
invalid regex because re.sub(rule.match_pattern, rule.replacement, value) raises
re.error; wrap every re.sub call in a try/except that catches re.error, logs the
failure (include rule.pk or rule.id and rule.match_pattern) and continues to the
next rule so a single bad rule doesn't stop processing; apply this change to
both the manufacturer-scoped loop (the loops over mfg_filter and rules) and the
unscoped branch, using the module logger (or logging.getLogger(__name__)) to
emit logger.error messages and leaving value unchanged when a rule is skipped.
- Around line 217-225: The current
DeviceTypeMapping.objects.get(librenms_hardware__iexact=hardware_name) call only
catches DoesNotExist but not DeviceTypeMapping.MultipleObjectsReturned; add an
except DeviceTypeMapping.MultipleObjectsReturned block (as used for the
part_number/model lookups) immediately after the DoesNotExist handler to
defensively handle multiple case-insensitive matches and treat it as no match
(i.e., fall through or return the same "no match" behavior as the other
lookups), optionally logging a debug message for visibility.

In `@netbox_librenms_plugin/views/base/cables_view.py`:
- Around line 58-61: The loop building local_ports_map can raise a KeyError when
port lacks the resolved interface_name_field; update the loop that iterates over
ports_data.get("ports", []) to safely access the name (use
port.get(interface_name_field) or check "if interface_name_field in port") and
skip or fallback when missing, e.g., continue and optionally log a warning so
local_ports_map only receives valid entries; make sure references to port_id
(str(port["port_id"])) remain guarded if port_id might also be missing.

In `@netbox_librenms_plugin/views/base/modules_view.py`:
- Around line 509-519: The code is comparing match.group(1) (a string) to
parent_bay.position (an int), which always fails; update the block that computes
expected_fpc to normalize it to an int before comparing: call int on
match.group(1) (e.g., expected_fpc = int(match.group(1))) and then compare
parent_bay.position == expected_fpc, catching ValueError and returning True
(preserving existing fallback behavior) so invalid numeric parses don't break
the check; adjust references in the function around candidate_name,
expected_fpc, bay.module, module.module_bay and parent_bay.position accordingly.
- Around line 785-793: The code reads parent_index from request.POST and casts
it with int(parent_index) which can raise ValueError for non-numeric input; wrap
the conversion in a try/except ValueError (or validate with str.isdigit()) and
on failure call messages.error(request, "Missing parent inventory index.") (or a
clearer message like "Invalid parent inventory index.") then build sync_url the
same way (reverse("plugins:netbox_librenms_plugin:device_librenms_sync",
kwargs={"pk": pk})) and return
redirect(f"{sync_url}?tab=modules#librenms-module-table"); update the logic
around the parent_index variable in the view (the block that sets parent_index
and then does int(parent_index)) to perform this validation before using the
integer value.
- Around line 1045-1050: The regex matching loop currently returns a bay from
BaseModuleTableView._lookup_regex_bay_mapping without applying the FPC
parent-slot validation used elsewhere, causing modules to be installed under the
wrong parent; modify the logic so that before returning a bay you apply the same
FPC parent-slot check used by BaseModuleTableView._fpc_slot_matches (either by
calling _fpc_slot_matches with the same parameters or adding that check inside
_lookup_regex_bay_mapping) so only matches that pass the FPC parent-slot
validation for the given candidate_names, phys_class and module_bays are
returned.
- Around line 81-85: The code currently assumes cache.ttl() exists (e.g., around
cache.set(...) in methods like get_cache_key/inventory caching in
modules_view.py and similar in interfaces_view.py, vlan_table_view.py,
ip_addresses_view.py, cables_view.py, devices.py), which breaks on backends that
don't implement ttl; change callers to first check getattr(cache, "ttl", None)
and if absent compute expiry from a stored cached-at timestamp (save a parallel
"<key>:cached_at" when setting cache) and use that fallback to determine
remaining TTL before rendering/sync; update all usages where cache.ttl() is
invoked to use this guarded lookup and fallback logic so non-Redis backends
don't raise AttributeError.

In `@netbox_librenms_plugin/views/imports/actions.py`:
- Around line 752-763: The code currently leaves device_type_synced = True when
librenms_hardware exists but match_librenms_hardware_to_device_type() returns no
mapping; change the logic so that when librenms_hardware is present (and not
"-") and hw_match.get("matched") is falsy, you set device_type_synced = False
and keep librenms_device_type as None; ensure this behavior is implemented
around the match_librenms_hardware_to_device_type call and referenced variables
(device_type_synced, librenms_device_type, librenms_hardware, hw_match,
existing_device.device_type) so an unknown mapping is treated as not synced
rather than a false positive.

In `@tests/e2e/test_module_install.py`:
- Around line 48-69: The helper _netbox_shell should enforce a subprocess
timeout and surface non-zero exits: when calling subprocess.run in _netbox_shell
add a timeout parameter (e.g., a few seconds) and catch
subprocess.TimeoutExpired to raise/return a clear error; after the call, check
result.returncode and if non-zero raise an exception that includes result.stderr
and result.stdout so failures aren’t silently treated as empty output; ensure
the exception message references the container and original command for easier
debugging.
- Around line 129-139: Replace all fixed time.sleep calls in this test file with
Playwright waits: in _goto_modules_tab, remove time.sleep(2) and time.sleep(8)
and instead wait for the page and the button reliably (use
page.wait_for_load_state("networkidle") or
page.wait_for_selector('button:has-text("Refresh Modules")') before clicking,
then wait for the network response or for a DOM change that indicates refresh
completion using page.wait_for_response(...) or page.wait_for_selector(...) for
the updated modules element); apply the same pattern to the other 11 occurrences
in this file—replace each time.sleep(...) with a targeted wait_for_selector,
wait_for_response, or wait_for_load_state tied to the UI element or API call
that indicates the operation finished so the tests become deterministic (search
for all instances of time.sleep in the file and update the surrounding logic).
- Around line 109-121: The page fixture logs in but lacks a post-login
auto-retrying assertion; after pg.click("button[type=submit]") and
pg.wait_for_load_state("networkidle") add Playwright expect-based checks to
verify login success (use expect(pg).to_have_url(...) with a stable path or
regex to avoid transient query params and also expect(pg.locator("text=Log out")
or a site-specific logout/user-menu selector). Update the page fixture to import
and use Playwright's expect and perform both the URL expectation and a visible
UI signal check so the fixture reliably fails on bad credentials; reference the
fixture name page and the pg variable when making these changes.
- Around line 31-45: The _get_container function's subprocess.run call should be
hardened by adding a short timeout and explicit error handling: invoke
subprocess.run with a timeout (e.g., timeout=5) and either check=True or inspect
result.returncode, catch subprocess.TimeoutExpired and
subprocess.CalledProcessError, and surface a clear failure (use pytest.skip or
pytest.fail with the exception/output details) instead of silently proceeding;
update references to CONTAINER_NAME and the docker ps invocation so the error
path logs or fails with the command stderr/stdout to aid diagnosis.

---

Outside diff comments:
In @.devcontainer/scripts/diagnose.sh:
- Around line 39-49: The PID check in the diagnose.sh snippet uses an unquoted
variable (PID) in the kill command which can produce a usage error if the PID
file is empty or malformed; update the kill test to use the quoted variable (use
"PID" in the kill invocation) and also guard against empty content by ensuring
PID is non-empty before calling kill (e.g., test -n "$PID" or treat empty as not
running) so the block around PID, kill -0, and the subsequent echo messages
(references: variable PID and the kill -0 check) handles empty/malformed PID
files safely.

In @.devcontainer/scripts/setup.sh:
- Around line 255-266: The script prints the newly created superuser password in
the python manage.py shell block (the print call that outputs Created superuser:
{username}/{password}), which leaks secrets; change the output to omit or mask
the password (e.g., only print the username or "password set" message) inside
that python block so the create_superuser call still confirms success but never
logs the plaintext password; update the print statements referenced in the
inline Python executed by python manage.py shell accordingly.
- Around line 251-252: The migrate step currently pipes manage.py migrate
through grep and appends "|| true", which masks any failure; update the setup to
run "python manage.py migrate" without the trailing "|| true" and without
filtering its stderr through grep so real errors surface, or alternatively
capture the migrate exit code (run manage.py migrate, save $? into a variable)
and if non-zero emit the full migrate output/error via echo/process logger and
exit with that non-zero code; locate the migrate invocation in the setup script
(the line that calls python manage.py migrate) and remove the silent-fail
pattern so critical migration failures cause the setup to fail visibly.

In @.devcontainer/scripts/start-netbox.sh:
- Around line 20-23: The debug echo that prints "🐛 Debug: ACCESS_URL is set to:
$ACCESS_URL" runs unconditionally and clutters normal local startup; either
remove that echo or guard it behind a verbosity flag (e.g., check a
DEBUG_VERBOSE or VERBOSE env var) before printing, so update start-netbox.sh to
only print the debug line when the flag is set (referencing ACCESS_URL and the
existing echo statement) and default to silence for normal runs.
- Around line 27-40: The orphan cleanup blocks currently kill processes
immediately with pkill -9; modify both the rqworker and runserver sections
(ORPHAN_RQ_PIDS and ORPHAN_NETBOX_PIDS) to first send a graceful SIGTERM (e.g.
pkill -15 -f "python.*rqworker" and pkill -15 -f "python.*runserver.*8000") when
pids are found, sleep briefly (e.g. 1-3s), then check whether the processes
remain and only then escalate to pkill -9; ensure you reuse the existing pgrep
checks and messages so the script still reports “Found orphaned …, killing…” but
performs the two-step TERM->KILL escalation.

In @.devcontainer/scripts/welcome.sh:
- Line 47: Fix the typo in the user-facing echo string: update the echo command
that prints "NetBox will be available at: http://localhost:8000 (paste into you
browser)" to use "your" instead of "you" so the message reads "(paste into your
browser)"; locate the echo statement in .devcontainer/scripts/welcome.sh and
change the string accordingly.

In @.github/workflows/test.yaml:
- Around line 52-56: The workflow title and its checkout step are inconsistent:
it currently says "Test with all supported NetBox versions" but the checkout in
the job uses actions/checkout@v4 with repository: "netbox-community/netbox" and
ref: main, so update the workflow to either add a matrix strategy (e.g., a
job-level matrix over NetBox refs/tags for the supported releases) and use the
matrix value for the checkout ref instead of the hardcoded ref: main, or change
the workflow name/title to indicate it only tests the main branch; locate the
job that contains uses: actions/checkout@v4 and ref: main and modify it
accordingly.

In `@netbox_librenms_plugin/tests/test_import_utils.py`:
- Around line 301-309: The test patches target external modules (e.g.,
virtualization.models.VirtualMachine) but import_utils uses its own imported
symbols, so replace those decorators to patch the symbols as they are imported
into import_utils (patch netbox_librenms_plugin.import_utils.VirtualMachine,
.Cluster, .Rack, .Site, .Device, .DeviceRole, .find_matching_site,
.find_matching_platform, .match_librenms_hardware_to_device_type) so the mocks
are applied where import_utils actually references them; update any similar
tests to patch the symbols on netbox_librenms_plugin.import_utils rather than
their original source modules.

In `@netbox_librenms_plugin/views/__init__.py`:
- Around line 5-90: Pyflakes reports F401 unused-imports because this package
__init__.py re-exports many view classes; fix by marking those imports as
intentional exports—either add “# noqa: F401” to each re-export import line
(e.g., lines importing BaseCableTableView, SingleCableVerifyView,
LibreNMSImportView, DeviceCableTableView, SyncCablesView, LibreNMSSettingsView,
etc.) or define an explicit __all__ tuple listing the exported symbols (include
names like BaseInterfaceTableView, BaseIPAddressTableView,
BulkInstallModulesView, DeviceTypeMappingListView, NormalizationRuleView,
DeviceLibreNMSSyncView, VMIPAddressTableView, TestLibreNMSConnectionView, etc.)
so linters recognize them as used.

In `@netbox_librenms_plugin/views/sync/cables.py`:
- Around line 90-103: The prereq check in verify_cable_creation_requirements
wrongly requires "netbox_remote_device_id" which is never consumed by
handle_cable_creation, causing valid links (with only interface IDs) to be
rejected; remove "netbox_remote_device_id" from the required_fields list in
verify_cable_creation_requirements so it only requires
"netbox_local_interface_id" and "netbox_remote_interface_id", and keep
handle_cable_creation as-is (or, alternatively, if remote device id is actually
needed, consume it inside handle_cable_creation where local/remote interface IDs
are read).
- Around line 50-59: The current create_cable/process_interface_sync flow
catches all Exceptions (including IntegrityError/DatabaseError) inside a
transaction.atomic block which can break the transaction and cause
TransactionManagementError on subsequent DB work; refactor so that database
errors are not swallowed inside the atomic scope: remove the broad try/except
that surrounds Cable.objects.create() within the atomic block in create_cable
(or process_interface_sync), let DB exceptions propagate so the atomic block
rolls back, and instead catch DatabaseError/IntegrityError outside the
transaction.atomic in the calling code (process_interface_sync) to record a
user-facing message (messages.error) and continue per-interface processing;
reference the functions create_cable and process_interface_sync to locate the
changes and ensure only non-database exceptions (if any) are handled inside the
atomic block.

In `@netbox_librenms_plugin/views/sync/interfaces.py`:
- Around line 158-185: The block in sync_interface that re-assigns
interface.enabled is redundant because update_interface_attributes already sets
and saves interface.enabled; remove the entire conditional that checks "enabled"
in exclude_columns and the nested assignment to interface.enabled from
sync_interface, leaving the VLAN sync and save logic
(vlan_synced/_sync_interface_vlans/_update_interface_vlan_assignment) intact so
no extra save occurs.

ℹ️ Review info

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

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 0220337 and e1381e2.

📒 Files selected for processing (74)
  • .devcontainer/README.md
  • .devcontainer/scripts/diagnose.sh
  • .devcontainer/scripts/load-aliases.sh
  • .devcontainer/scripts/setup.sh
  • .devcontainer/scripts/start-netbox.sh
  • .devcontainer/scripts/welcome.sh
  • .github/workflows/lint-format.yaml
  • .github/workflows/test.yaml
  • contrib/README.md
  • contrib/device_type_mappings.yaml
  • contrib/interface_name_rules.yaml
  • contrib/interface_type_mappings.yaml
  • contrib/module_bay_mappings.yaml
  • contrib/module_type_mappings.yaml
  • contrib/normalization_rules.yaml
  • docs/usage_tips/custom_field.md
  • docs/usage_tips/permissions.md
  • netbox_librenms_plugin/__init__.py
  • netbox_librenms_plugin/api/serializers.py
  • netbox_librenms_plugin/api/urls.py
  • netbox_librenms_plugin/api/views.py
  • netbox_librenms_plugin/filters.py
  • netbox_librenms_plugin/forms.py
  • netbox_librenms_plugin/import_utils.py
  • netbox_librenms_plugin/librenms_api.py
  • netbox_librenms_plugin/migrations/0009_add_devicetypemapping.py
  • netbox_librenms_plugin/migrations/0010_add_moduletypemapping.py
  • netbox_librenms_plugin/migrations/0011_modulebaymapping.py
  • netbox_librenms_plugin/migrations/0012_add_is_regex_to_modulebaymapping.py
  • netbox_librenms_plugin/migrations/0013_normalizationrule.py
  • netbox_librenms_plugin/models.py
  • netbox_librenms_plugin/navigation.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/mappings.py
  • netbox_librenms_plugin/tables/modules.py
  • netbox_librenms_plugin/templates/netbox_librenms_plugin/_interface_sync_content.html
  • netbox_librenms_plugin/templates/netbox_librenms_plugin/_module_sync.html
  • netbox_librenms_plugin/templates/netbox_librenms_plugin/_module_sync_content.html
  • netbox_librenms_plugin/templates/netbox_librenms_plugin/devicetypemapping.html
  • netbox_librenms_plugin/templates/netbox_librenms_plugin/devicetypemapping_list.html
  • netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_validation_details.html
  • netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_import.html
  • netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html
  • netbox_librenms_plugin/templates/netbox_librenms_plugin/modulebaymapping.html
  • netbox_librenms_plugin/templates/netbox_librenms_plugin/modulebaymapping_list.html
  • netbox_librenms_plugin/templates/netbox_librenms_plugin/moduletypemapping.html
  • netbox_librenms_plugin/templates/netbox_librenms_plugin/moduletypemapping_list.html
  • netbox_librenms_plugin/templates/netbox_librenms_plugin/normalizationrule.html
  • netbox_librenms_plugin/templates/netbox_librenms_plugin/normalizationrule_list.html
  • netbox_librenms_plugin/tests/test_import_utils.py
  • netbox_librenms_plugin/tests/test_init.py
  • netbox_librenms_plugin/tests/test_librenms_api.py
  • netbox_librenms_plugin/tests/test_utils.py
  • netbox_librenms_plugin/urls.py
  • netbox_librenms_plugin/utils.py
  • netbox_librenms_plugin/views/__init__.py
  • netbox_librenms_plugin/views/base/cables_view.py
  • netbox_librenms_plugin/views/base/librenms_sync_view.py
  • netbox_librenms_plugin/views/base/modules_view.py
  • netbox_librenms_plugin/views/imports/__init__.py
  • netbox_librenms_plugin/views/imports/actions.py
  • netbox_librenms_plugin/views/mapping_views.py
  • netbox_librenms_plugin/views/object_sync/__init__.py
  • netbox_librenms_plugin/views/object_sync/devices.py
  • netbox_librenms_plugin/views/sync/cables.py
  • netbox_librenms_plugin/views/sync/device_fields.py
  • netbox_librenms_plugin/views/sync/devices.py
  • netbox_librenms_plugin/views/sync/interfaces.py
  • netbox_librenms_plugin/views/sync/locations.py
  • tests/e2e/__init__.py
  • tests/e2e/conftest.py
  • tests/e2e/test_module_install.py
💤 Files with no reviewable changes (2)
  • netbox_librenms_plugin/templates/netbox_librenms_plugin/_interface_sync_content.html
  • netbox_librenms_plugin/views/sync/locations.py

Comment thread .github/workflows/test.yaml Outdated
Comment thread netbox_librenms_plugin/__init__.py Outdated
Comment on lines +46 to +53
class NormalizationRuleFilterSet(django_filters.FilterSet):
"""Filter set for NormalizationRule model."""

class Meta:
"""Meta options for NormalizationRuleFilterSet."""

model = NormalizationRule
fields = ["scope", "manufacturer"]

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 | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

find . -type f -name "*.py" | xargs grep -l "class NormalizationRule" | head -5

Repository: marcinpsk/netbox-librenms-plugin

Length of output: 262


🏁 Script executed:

# Look for the NormalizationRule model definition
rg -n "class NormalizationRule" --type=py -A 15

Repository: marcinpsk/netbox-librenms-plugin

Length of output: 13694


🏁 Script executed:

# Check the filters.py file to see the current setup
rg -n "NormalizationRuleFilterSet" --type=py -A 20

Repository: marcinpsk/netbox-librenms-plugin

Length of output: 4154


🏁 Script executed:

cat -n netbox_librenms_plugin/models.py | sed -n '205,270p'

Repository: marcinpsk/netbox-librenms-plugin

Length of output: 2823


manufacturer filter on NormalizationRuleFilterSet — clarify or add explicit filter configuration.

The manufacturer field is a ForeignKey (line 235 in models.py), so django-filters auto-generates a PK-based filter. This works but creates inconsistency: NormalizationRuleFilterForm uses manufacturer_id with DynamicModelChoiceField, suggesting the filter expects PK. Add an explicit ModelChoiceFilter to the FilterSet to make the expected behavior clear and consistent with the form.

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

In `@netbox_librenms_plugin/filters.py` around lines 46 - 53, The filter set
relies on django-filters' implicit PK filter for the ForeignKey manufacturer,
causing inconsistency with the form; explicitly declare a ModelChoiceFilter
named manufacturer on NormalizationRuleFilterSet that points to the
NormalizationRule.manufacturer field, uses the Manufacturer queryset, and sets
the filter's field_class to DynamicModelChoiceField (or otherwise matches the
DynamicModelChoiceField used in NormalizationRuleFilterForm); also import
Manufacturer and DynamicModelChoiceField and remove reliance on implicit
generation so the filter behavior is explicit and consistent with the form.

Comment thread netbox_librenms_plugin/forms.py
Comment thread netbox_librenms_plugin/import_utils.py Outdated
Comment on lines 825 to 869
if not result["existing_device"]:
try:
existing_device = Device.objects.filter(custom_field_data__librenms_id=int(librenms_id)).first()
except (ValueError, TypeError):
# librenms_id is not convertible to int; no match will be found
existing_device = None

if existing_device:
logger.info(f"Found existing device: {existing_device.name} (matched by librenms_id={librenms_id})")
result["existing_device"] = existing_device
result["existing_match_type"] = "librenms_id"
result["can_import"] = False

# Check if name matches sysName
sys_name = libre_device.get("sysName") or ""
if sys_name and existing_device.name == sys_name:
result["name_matches"] = True
elif sys_name and existing_device.name != sys_name:
result["name_sync_available"] = True
result["suggested_name"] = sys_name

# Check for serial drift on the linked device
incoming_serial = libre_device.get("serial") or ""
if incoming_serial and incoming_serial != "-":
if existing_device.serial and existing_device.serial == incoming_serial:
result["serial_confirmed"] = True
elif existing_device.serial and existing_device.serial != incoming_serial:
serial_conflict = (
Device.objects.filter(serial=incoming_serial).exclude(pk=existing_device.pk).first()
)
if serial_conflict:
result["serial_action"] = "conflict"
result["serial_duplicate"] = True
result["warnings"].append(
f"Serial conflict: incoming serial '{incoming_serial}' is already assigned to "
f"device '{serial_conflict.name}' (ID: {serial_conflict.pk}) in NetBox. "
f"Investigate which device should own this serial before updating."
)
else:
result["serial_action"] = "update_serial"
result["warnings"].append(
f"Serial number differs (NetBox: '{existing_device.serial}', "
f"LibreNMS: '{incoming_serial}'). Hardware may have been replaced."
)

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

Avoid direct custom_field_data__librenms_id lookups; use the helper.

These new lookups bypass the plugin’s LibreNMSAPI.get_librenms_id helper, which is the standard way to access the mapping and handle config nuances. Please refactor these checks to go through the helper (or a shared accessor that delegates to it) for both Devices and VMs.

As per coding guidelines, "Always call LibreNMSAPI.get_librenms_id to retrieve the device/VM LibreNMS mapping via the librenms_id custom field instead of touching the field directly."

🤖 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 825 - 869, This code
directly queries custom_field_data__librenms_id; instead call the plugin helper
LibreNMSAPI.get_librenms_id to locate a matching Device/VM. Replace
Device.objects.filter(custom_field_data__librenms_id=int(librenms_id)).first()
with a lookup that uses LibreNMSAPI.get_librenms_id(librenms_id) (or a shared
accessor that delegates to that method) to return the mapped object, then
proceed to set result["existing_device"], result["existing_match_type"], and the
serial/name checks as before; ensure you handle non-int librenms_id exceptions
the same way and apply the same refactor for any VM lookups in this module.

Comment thread netbox_librenms_plugin/views/imports/actions.py
Comment on lines +31 to +45
def _get_container():
"""Find the devcontainer name."""
global CONTAINER_NAME
if CONTAINER_NAME:
return CONTAINER_NAME
result = subprocess.run(
["docker", "ps", "--format", "{{.Names}}"],
capture_output=True,
text=True,
)
for name in result.stdout.strip().split("\n"):
if "devcontainer-devcontainer" in name:
CONTAINER_NAME = name
return name
pytest.skip("No devcontainer found")

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

🧩 Analysis chain

🌐 Web query:

Python subprocess.run timeout best practices error handling

💡 Result:

Best practices for subprocess.run(..., timeout=...) + error handling

  1. Prefer subprocess.run() over Popen when possible (simpler, safer defaults for common cases). It returns a CompletedProcess with args, returncode, stdout, stderr. [1]
  2. Always set a timeout for potentially-hanging commands. The timeout is passed to Popen.communicate(); if it expires, the child is killed, waited for, and then TimeoutExpired is raised. (Process creation itself may not be interruptible immediately on some platforms.) [1]
  3. Use check=True when a non-zero exit code should be treated as failure. This raises CalledProcessError and can include captured output. [1]
  4. Capture output intentionally (and avoid pipe deadlocks). Use capture_output=True or stdout=PIPE/stderr=PIPE (or stderr=STDOUT to merge). run() handles the “read pipes while waiting” pattern internally via communicate(). [1]
  5. Decode output explicitly. Use text=True (optionally with encoding= / errors=) to get strings instead of bytes. [1]
  6. Handle “can’t execute” separately from “command failed.” Missing executable typically surfaces as OSError/FileNotFoundError; a found executable that exits non-zero is CalledProcessError (if check=True). [1]
  7. Avoid shell=True with untrusted input. If you invoke a shell, you must quote/escape to prevent shell-injection. Prefer passing an argument list with shell=False. [1]
  8. Log/debug with context. On exceptions, include: the command (exc.cmd / exc.args), returncode, and any captured stdout/stderr (noting TimeoutExpired.output/stdout/stderr are bytes if captured). [1]

Robust pattern (drop-in helper)

import subprocess

def run_cmd(args, *, timeout=30, cwd=None, env=None):
    try:
        cp = subprocess.run(
            args,
            cwd=cwd,
            env=env,
            timeout=timeout,
            check=True,             # raise CalledProcessError on non-zero exit
            text=True,              # decode stdout/stderr to str
            capture_output=True,    # or use stdout=PIPE, stderr=STDOUT, etc.
        )
        return cp.stdout
    except subprocess.TimeoutExpired as e:
        # e.cmd, e.timeout; e.stdout/e.stderr may be present (often bytes)
        raise RuntimeError(f"Timed out after {e.timeout}s: {e.cmd}") from e
    except subprocess.CalledProcessError as e:
        # e.returncode, e.stdout, e.stderr (if captured)
        raise RuntimeError(
            f"Command failed ({e.returncode}): {e.cmd}\nSTDERR:\n{e.stderr}"
        ) from e
    except (FileNotFoundError, OSError) as e:
        raise RuntimeError(f"Executable not found / could not start: {args}") from e

Sources: Python subprocess docs (run/timeout behavior, exceptions, output capture, security considerations). [1] Real-world exception patterns/examples. [2]

References:
[1] Python 3.14 subprocess documentation — https://docs.python.org/3/library/subprocess.html
[2] Real Python: “The subprocess Module: Wrapping Programs With Python” — https://realpython.com/python-subprocess/


🏁 Script executed:

python3 << 'EOF'
import subprocess
import sys

# Verify subprocess.run behavior without check=True
print("subprocess.run behavior without check=True:")
print("- By default, returncode is NOT raised as exception")
print("- returncode is stored in CompletedProcess object")
print("- Need explicit check or returncode handling")
print()
print("timeout parameter:")
print("- Valid parameter for subprocess.run")
print("- Raises TimeoutExpired exception if timeout exceeded")
print("- Common pattern: wrap in try-except")
print()
print("Current code issue:")
print("- No timeout: can hang indefinitely if docker stalls")
print("- No error handling: fails silently with pytest.skip if docker ps returns non-zero")
print("- Result: test unreliability in CI/flaky tests")
EOF

Repository: marcinpsk/netbox-librenms-plugin

Length of output: 618


🏁 Script executed:

rg "subprocess.run" tests/e2e/ -A 5

Repository: marcinpsk/netbox-librenms-plugin

Length of output: 784


🏁 Script executed:

rg "pytest.skip" tests/ -B 2 -A 2 | head -40

Repository: marcinpsk/netbox-librenms-plugin

Length of output: 341


Harden docker discovery with timeout and error handling.

The subprocess.run() call lacks a timeout, which can cause the test to hang indefinitely if the Docker daemon becomes unresponsive. Additionally, non-zero exit codes from the docker command are silently ignored, making failures harder to diagnose. Add a short timeout and explicit error handling.

🔧 Suggested update
     result = subprocess.run(
         ["docker", "ps", "--format", "{{.Names}}"],
         capture_output=True,
         text=True,
+        timeout=10,
     )
+    if result.returncode != 0:
+        pytest.skip(f"Docker ps failed: {result.stderr.strip() or 'unknown error'}")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/e2e/test_module_install.py` around lines 31 - 45, The _get_container
function's subprocess.run call should be hardened by adding a short timeout and
explicit error handling: invoke subprocess.run with a timeout (e.g., timeout=5)
and either check=True or inspect result.returncode, catch
subprocess.TimeoutExpired and subprocess.CalledProcessError, and surface a clear
failure (use pytest.skip or pytest.fail with the exception/output details)
instead of silently proceeding; update references to CONTAINER_NAME and the
docker ps invocation so the error path logs or fails with the command
stderr/stdout to aid diagnosis.

Comment on lines +48 to +69
def _netbox_shell(code):
"""Run Python code in NetBox's Django shell."""
import shlex

container = _get_container()
escaped = shlex.quote(code)
result = subprocess.run(
[
"docker",
"exec",
container,
"bash",
"-c",
f"cd /opt/netbox/netbox && python3 manage.py shell -c {escaped}",
],
capture_output=True,
text=True,
env={"PATH": "/usr/bin:/bin", "HOME": "/root"},
)
# Filter out config loading lines
lines = [line for line in result.stdout.strip().split("\n") if not line.startswith(("🧬", "156 objects"))]
return "\n".join(lines).strip()

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

🧩 Analysis chain

🏁 Script executed:

# First, let's look at the actual test file to verify the current state
cd /
find . -name "test_module_install.py" -path "*/e2e/*" 2>/dev/null | head -1

Repository: marcinpsk/netbox-librenms-plugin

Length of output: 128


🏁 Script executed:

# Find and read the test file
git ls-files | grep -E "tests/e2e.*test_module_install"

Repository: marcinpsk/netbox-librenms-plugin

Length of output: 108


🏁 Script executed:

# Try to locate the file more broadly
find . -type f -name "test_module_install.py" 2>/dev/null

Repository: marcinpsk/netbox-librenms-plugin

Length of output: 110


🏁 Script executed:

# List test directory structure
git ls-files | grep "tests/e2e"

Repository: marcinpsk/netbox-librenms-plugin

Length of output: 152


🏁 Script executed:

# Check if the tests directory exists
ls -la tests/ 2>/dev/null || echo "No tests dir at root"

Repository: marcinpsk/netbox-librenms-plugin

Length of output: 239


🏁 Script executed:

# Read the test file
cat -n tests/e2e/test_module_install.py

Repository: marcinpsk/netbox-librenms-plugin

Length of output: 15078


🏁 Script executed:

# Also check testing.instructions.md for e2e conventions
cat -n .github/instructions/testing.instructions.md 2>/dev/null || echo "File not found"

Repository: marcinpsk/netbox-librenms-plugin

Length of output: 4315


Add timeout and error handling to _netbox_shell to prevent hangs and silent failures.

_netbox_shell is used by multiple test assertions; without a timeout and return-code check, docker exec failures go undetected and return empty output, causing misleading test failures or hangs.

Suggested fix
     result = subprocess.run(
         [
             "docker",
             "exec",
             container,
             "bash",
             "-c",
             f"cd /opt/netbox/netbox && python3 manage.py shell -c {escaped}",
         ],
         capture_output=True,
         text=True,
         env={"PATH": "/usr/bin:/bin", "HOME": "/root"},
+        timeout=60,
     )
+    if result.returncode != 0:
+        pytest.fail(f"Docker exec failed: {result.stderr.strip() or 'unknown error'}")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/e2e/test_module_install.py` around lines 48 - 69, The helper
_netbox_shell should enforce a subprocess timeout and surface non-zero exits:
when calling subprocess.run in _netbox_shell add a timeout parameter (e.g., a
few seconds) and catch subprocess.TimeoutExpired to raise/return a clear error;
after the call, check result.returncode and if non-zero raise an exception that
includes result.stderr and result.stdout so failures aren’t silently treated as
empty output; ensure the exception message references the container and original
command for easier debugging.

Comment on lines +109 to +121
@pytest.fixture
def page(browser):
"""Create a new page and log in to NetBox."""
ctx = browser.new_context(ignore_https_errors=True)
pg = ctx.new_page()

pg.goto(f"{NETBOX_URL}/login/", timeout=10000)
pg.fill("#id_username", NETBOX_USER)
pg.fill("#id_password", NETBOX_PASS)
pg.click("button[type=submit]")
pg.wait_for_load_state("networkidle")
yield pg
ctx.close()

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

🧩 Analysis chain

🏁 Script executed:

find . -name "test_module_install.py" -type f | head -20

Repository: marcinpsk/netbox-librenms-plugin

Length of output: 110


🏁 Script executed:

git ls-files tests/e2e/

Repository: marcinpsk/netbox-librenms-plugin

Length of output: 152


🌐 Web query:

Playwright page.url assertion login verification best practices

💡 Result:

URL assertions after login (Playwright) — best practices

  1. Prefer auto-retrying URL assertions over page.url() comparisons

    • Use await expect(page).toHaveURL(...) so Playwright waits for navigation/redirects instead of doing a one-time check (less flaky). [1]
    await page.getByRole('button', { name: 'Log in' }).click();
    await expect(page).toHaveURL(/\/dashboard(?:\?|$)/);

    Playwright’s web-first assertions auto-retry until they pass or time out. [1]

  2. Match only the stable part of the URL

    • Use regex or partial matching to avoid failures from transient query params (?next=..., ?utm=...) or dynamic segments. [1]
    await expect(page).toHaveURL(/\/account\/settings/);
  3. Don’t rely on URL alone to prove you’re logged in

    • Also assert a post-login UI signal (e.g., avatar/menu, “Log out” button, user name, or a dashboard heading) using locators—these are resilient and auto-wait. [4]
    await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
    await expect(page.getByRole('button', { name: /log out/i })).toBeVisible();
  4. Lean on Playwright’s auto-waiting; avoid manual sleeps

    • Actions and many assertions already wait for the page to be in an actionable/ready state; waitForTimeout() usually adds flakiness and slowdowns. [2]
  5. For test suites: don’t UI-login in every test

    • Do one authenticated “setup” login, save storageState, and reuse it for other tests to speed up runs and reduce login flake. [3]
    • Keep at least one end-to-end test that performs the real UI login flow. [3]

Sources: Playwright Assertions & auto-retry behavior [1], auto-waiting/actionability [2], authentication & storageState guidance [3], locator-based best practices [4].


🏁 Script executed:

cat -n tests/e2e/test_module_install.py | sed -n '100,130p'

Repository: marcinpsk/netbox-librenms-plugin

Length of output: 1113


Verify login success using Playwright best practices.

The fixture lacks a post-login assertion, which can cause false positives if credentials fail. However, avoid simple page.url checks, which don't auto-retry and can be flaky with transient query params. Instead, use Playwright's auto-retrying assertion and combine it with a UI signal check:

🔧 Suggested update
     pg.click("button[type=submit]")
     pg.wait_for_load_state("networkidle")
+    from playwright.sync_api import expect
+    expect(pg).to_have_url("/admin/")  # or the post-login landing page
+    expect(pg.get_by_role("button", name="Log out")).to_be_visible()  # verify UI signal
     yield pg
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/e2e/test_module_install.py` around lines 109 - 121, The page fixture
logs in but lacks a post-login auto-retrying assertion; after
pg.click("button[type=submit]") and pg.wait_for_load_state("networkidle") add
Playwright expect-based checks to verify login success (use
expect(pg).to_have_url(...) with a stable path or regex to avoid transient query
params and also expect(pg.locator("text=Log out") or a site-specific
logout/user-menu selector). Update the page fixture to import and use
Playwright's expect and perform both the URL expectation and a visible UI signal
check so the fixture reliably fails on bad credentials; reference the fixture
name page and the pg variable when making these changes.

Comment on lines +129 to +139
def _goto_modules_tab(self, page):
"""Navigate to the modules sync tab and refresh data."""
page.goto(f"{NETBOX_URL}/dcim/devices/{self.DEVICE_ID}/librenms-sync/?tab=modules")
page.wait_for_load_state("networkidle")
time.sleep(2)

# Click Refresh Modules
btn = page.query_selector('button:has-text("Refresh Modules")')
assert btn is not None, "Refresh Modules button not found"
btn.click()
time.sleep(8)

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

🧩 Analysis chain

🏁 Script executed:

# First, let's read the test file to see the full context and all sleep calls
cat -n tests/e2e/test_module_install.py | head -200

Repository: marcinpsk/netbox-librenms-plugin

Length of output: 8419


🏁 Script executed:

# Count all time.sleep calls in the file
rg "time\.sleep" tests/e2e/test_module_install.py -n

Repository: marcinpsk/netbox-librenms-plugin

Length of output: 451


🏁 Script executed:

# Check if the selectors are used in templates or views
rg "#modules table" --type html --type py

Repository: marcinpsk/netbox-librenms-plugin

Length of output: 58


🏁 Script executed:

# Look for how Playwright is typically used in other test files
find tests -name "*.py" -type f | head -10

Repository: marcinpsk/netbox-librenms-plugin

Length of output: 152


Replace fixed sleeps with Playwright waits.

Static sleeps are flaky and slow. Prefer waiting on selectors or network idle. The pattern applies to all 12 time.sleep() calls in this file (lines 133, 139, 188, 215, 217, 247, 249, 286, 297, 301, 312, 316), not just the ones in _goto_modules_tab.

🔧 Suggested update for _goto_modules_tab
     page.goto(f"{NETBOX_URL}/dcim/devices/{self.DEVICE_ID}/librenms-sync/?tab=modules")
     page.wait_for_load_state("networkidle")
-    time.sleep(2)
+    page.wait_for_selector("#modules table")

     # Click Refresh Modules
     btn = page.query_selector('button:has-text("Refresh Modules")')
     assert btn is not None, "Refresh Modules button not found"
     btn.click()
-    time.sleep(8)
+    page.wait_for_load_state("networkidle")
+    page.wait_for_selector("#modules table tr")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/e2e/test_module_install.py` around lines 129 - 139, Replace all fixed
time.sleep calls in this test file with Playwright waits: in _goto_modules_tab,
remove time.sleep(2) and time.sleep(8) and instead wait for the page and the
button reliably (use page.wait_for_load_state("networkidle") or
page.wait_for_selector('button:has-text("Refresh Modules")') before clicking,
then wait for the network response or for a DOM change that indicates refresh
completion using page.wait_for_response(...) or page.wait_for_selector(...) for
the updated modules element); apply the same pattern to the other 11 occurrences
in this file—replace each time.sleep(...) with a targeted wait_for_selector,
wait_for_response, or wait_for_load_state tied to the UI element or API call
that indicates the operation finished so the tests become deterministic (search
for all instances of time.sleep in the file and update the surrounding logic).

ghost 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: 5

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/sync/interfaces.py (1)

172-174: 🧹 Nitpick | 🔵 Trivial

Redundant save when VLANs are excluded.

When "vlans" is in exclude_columns, vlan_synced remains False, triggering the save at line 174. However, update_interface_attributes already calls interface.save() at line 251. This results in an unnecessary database write.

Consider removing the save from update_interface_attributes (line 251) and relying solely on the conditional save here, or removing this conditional save entirely since update_interface_attributes always saves.

♻️ Option 1: Remove redundant conditional save
         # Sync VLANs if not excluded
-        vlan_synced = False
         if "vlans" not in exclude_columns:
             self._sync_interface_vlans(interface, librenms_interface, interface_name)
-            vlan_synced = True
-
-        # Skip redundant save when _sync_interface_vlans already saved (via _update_interface_vlan_assignment)
-        if not vlan_synced:
-            interface.save()
🤖 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 172 - 174,
Remove the redundant conditional save here: delete the block that checks
vlan_synced and calls interface.save() (the "if not vlan_synced:
interface.save()" around vlan_synced) because update_interface_attributes
already performs the persistent save; ensure update_interface_attributes remains
responsible for persisting changes and that exclude_columns/"vlans" handling
still avoids double-writing by relying on vlan_synced logic only for
VLAN-specific ops rather than an extra save.
netbox_librenms_plugin/forms.py (1)

778-806: ⚠️ Potential issue | 🟠 Major

Scope location cache key by LibreNMS server.

Similar to the fix applied in _get_librenms_poller_group_choices, the location cache key should be server-scoped to avoid returning incorrect data when switching between LibreNMS servers.

🛠️ Suggested fix
     def _populate_librenms_locations(self):
         """Fetch and populate LibreNMS locations in the dropdown."""
         from django.core.cache import cache

         from netbox_librenms_plugin.librenms_api import LibreNMSAPI

         try:
-            # Use caching to avoid repeated API calls
-            cache_key = "librenms_locations_choices"
+            # Fetch locations from LibreNMS
+            api = LibreNMSAPI()
+            # Use server-scoped caching to avoid incorrect data when switching servers
+            server_id = api.librenms_url.rstrip("/")
+            cache_key = f"librenms_locations_choices_{server_id}"
             cached_choices = cache.get(cache_key)

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

-            # Fetch locations from LibreNMS
-            api = LibreNMSAPI()
             success, locations = api.get_locations()

As per coding guidelines "Reuse the librenms_api.py LibreNMS client instead of making new requests calls; it handles multi-server configs via LibreNMSSettings model and the servers plugin config, plus caching".

🤖 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 778 - 806, The locations cache
key is global and must be server-scoped; instead of the static
"librenms_locations_choices" build the key using the LibreNMS client instance so
it is unique per server (e.g. after creating api = LibreNMSAPI(), compute
cache_key = f"librenms_locations_choices:{api.server_key_or_host_or_id}" where
server_key_or_host_or_id is the server-identifying attribute available on
LibreNMSAPI such as server, host, base_url or id), then use that cache_key for
cache.get and cache.set and continue to use api.cache_timeout; mirror the
pattern used in _get_librenms_poller_group_choices and ensure you reuse the
LibreNMSAPI() instance rather than creating raw requests.
♻️ Duplicate comments (3)
netbox_librenms_plugin/views/base/modules_view.py (2)

240-241: ⚠️ Potential issue | 🟠 Major

Guard cache.ttl() for non-Redis cache backends.

Line 240 assumes ttl() exists on the active cache backend. On backends without ttl, this raises AttributeError and breaks module sync rendering.

#!/bin/bash
# Verify unguarded cache.ttl usage in views
rg -n "cache\.ttl\(" netbox_librenms_plugin/views --type py
rg -n -C2 "cache\.ttl\(" netbox_librenms_plugin/views/base/modules_view.py
🔧 Proposed fix
-        cache_ttl = cache.ttl(self.get_cache_key(obj, "inventory"))
-        cache_expiry = timezone.now() + timezone.timedelta(seconds=cache_ttl) if cache_ttl is not None else None
+        cache_expiry = None
+        ttl_fn = getattr(cache, "ttl", None)
+        if callable(ttl_fn):
+            cache_ttl = ttl_fn(self.get_cache_key(obj, "inventory"))
+            if cache_ttl is not None:
+                cache_expiry = timezone.now() + timezone.timedelta(seconds=cache_ttl)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@netbox_librenms_plugin/views/base/modules_view.py` around lines 240 - 241,
The code calls cache.ttl(...) unguarded which will raise on backends lacking
ttl; update the logic around cache_ttl = cache.ttl(self.get_cache_key(obj,
"inventory")) in the modules view to first check for the existence of ttl (e.g.,
use hasattr(cache, "ttl") or getattr(cache, "ttl", None)) and only call it when
present, falling back to None otherwise, then compute cache_expiry =
timezone.now() + timezone.timedelta(seconds=cache_ttl) if cache_ttl is not None
else None; refer to the cache_ttl variable and the get_cache_key(self, obj,
"inventory") call to locate where to add the guard.

512-519: ⚠️ Potential issue | 🟠 Major

Normalize the captured FPC slot to int before comparing bay positions.

Line 512 keeps expected_fpc as string, then Line 519 compares it to parent_bay.position (numeric), causing false mismatches.

#!/bin/bash
# Verify the current string-vs-position comparison in _fpc_slot_matches
rg -n -C2 "expected_fpc = match.group\(1\)|parent_bay.position == expected_fpc" netbox_librenms_plugin/views/base/modules_view.py
🐛 Proposed fix
-        expected_fpc = match.group(1)
+        try:
+            expected_fpc = int(match.group(1))
+        except ValueError:
+            return True
...
         return parent_bay.position == expected_fpc
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@netbox_librenms_plugin/views/base/modules_view.py` around lines 512 - 519,
The captured slot value expected_fpc (from match.group(1)) is currently a string
and is being compared to parent_bay.position (numeric), causing mismatches;
update the _fpc_slot_matches logic so that after obtaining expected_fpc you
coerce it to int (e.g., parse int(match.group(1))) and handle parse errors by
returning False (or falling back appropriately) before comparing to
parent_bay.position; locate the code around expected_fpc, bay, module, and
parent_bay (module.module_bay) to apply this change.
netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html (1)

575-579: 🛠️ Refactor suggestion | 🟠 Major

Move _module_sync.html include under inc/.

Line 578 still references a reusable partial outside inc/.

📁 Proposed fix
-        {% include 'netbox_librenms_plugin/_module_sync.html' %}
+        {% include 'netbox_librenms_plugin/inc/_module_sync.html' %}

As per coding guidelines: Templates should live in templates/netbox_librenms_plugin/. Reusable template includes should be placed under inc/ subdirectory.

🤖 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 575 - 579, Update the include in librenms_sync_base.html that
currently references '_module_sync.html' so it points to the reusable partial
under the inc subfolder (i.e., change the include from
'netbox_librenms_plugin/_module_sync.html' to
'netbox_librenms_plugin/inc/_module_sync.html'), and move the
'_module_sync.html' partial file into the inc/ directory so the template loader
finds it; ensure any other references use the new inc/_module_sync.html path.
🤖 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/__init__.py`:
- Around line 86-125: The try/except can raise NameError because logging is
imported inside the try; move the logging import out of the try block (or ensure
logging is imported in the except) so
logging.getLogger("netbox_librenms_plugin").exception(...) always has logging
defined; locate the block using CustomField, ContentType and the models Device,
VirtualMachine, Interface, VMInterface (and the cf.object_types operations) and
import logging before that try so the except can safely log errors.

In `@netbox_librenms_plugin/views/base/cables_view.py`:
- Around line 59-62: The code is converting port.get("port_id") to a string up
front which turns None into "None" and bypasses the falsy check; change the
logic in the loop that builds local_ports_map so you first retrieve raw_port_id
= port.get("port_id") and validate it (skip if raw_port_id is None or empty),
only then convert with port_id = str(raw_port_id) and ensure
interface_name_field lookup (port.get(interface_name_field)) is still validated;
ensure local_ports_map keys are created from the validated str(port_id) and that
lookups using link.get("local_port_id") also convert/validate consistently to
avoid matching the bogus "None" key.

In `@netbox_librenms_plugin/views/imports/actions.py`:
- Around line 857-865: The code casts libre_device.get("device_id") to int
without validation (used when setting
existing_device.custom_field_data["librenms_id"] and elsewhere), which can raise
a 500 if device_id is missing or non-numeric; update the handler around the
librenms_id usage to validate that libre_device contains a present, numeric
"device_id" before calling int(), e.g., check libre_device.get("device_id") is
not None and str(librenms_id).isdigit() (or try/except ValueError around int()),
and if invalid return a controlled error (HttpResponseBadRequest or a validation
error) or skip linking; apply the same guard to the other int(...) casts
referenced near lines with existing_device.custom_field_data["librenms_id"] and
the other occurrences at the same function handling actions "link" (and the
other two spots noted).
- Around line 819-840: The post handler in DeviceConflictActionView currently
trusts existing_device_id from POST and only checks global write permission;
instead bind conflict operations to the already-validated target (use the
libre_device returned by get_validated_device_with_selections) and perform
object-level authorization on any existing_device before mutating it: resolve
existing_device via the validated selections or ensure existing_device.pk
matches the validated libre_device.pk (or call the appropriate object-permission
check for the target Device) and reject mismatches or unauthorized access with a
403; update any subsequent logic that uses existing_device to operate only on
the authorized/validated Device instance (references:
DeviceConflictActionView.post, existing_device_id, existing_device,
libre_device, get_validated_device_with_selections).

In `@netbox_librenms_plugin/views/sync/cables.py`:
- Around line 126-129: The loop over selected_interfaces uses a single
transaction.atomic() around process_single_interface which allows exceptions
from process_single_interface to abort the whole sync; wrap each interface
processing in its own try/except inside the transaction so one failure doesn't
stop the loop: for each interface call transaction.atomic(), call
self.process_single_interface(interface, cached_links) inside a try block, on
exception catch/log the error and append a failure result into results["failed"]
(or appropriate status) using the interface identifier, and continue to the next
interface; reference functions/variables: process_single_interface,
selected_interfaces, cached_links, results, and transaction.atomic.

---

Outside diff comments:
In `@netbox_librenms_plugin/forms.py`:
- Around line 778-806: The locations cache key is global and must be
server-scoped; instead of the static "librenms_locations_choices" build the key
using the LibreNMS client instance so it is unique per server (e.g. after
creating api = LibreNMSAPI(), compute cache_key =
f"librenms_locations_choices:{api.server_key_or_host_or_id}" where
server_key_or_host_or_id is the server-identifying attribute available on
LibreNMSAPI such as server, host, base_url or id), then use that cache_key for
cache.get and cache.set and continue to use api.cache_timeout; mirror the
pattern used in _get_librenms_poller_group_choices and ensure you reuse the
LibreNMSAPI() instance rather than creating raw requests.

In `@netbox_librenms_plugin/views/sync/interfaces.py`:
- Around line 172-174: Remove the redundant conditional save here: delete the
block that checks vlan_synced and calls interface.save() (the "if not
vlan_synced: interface.save()" around vlan_synced) because
update_interface_attributes already performs the persistent save; ensure
update_interface_attributes remains responsible for persisting changes and that
exclude_columns/"vlans" handling still avoids double-writing by relying on
vlan_synced logic only for VLAN-specific ops rather than an extra save.

---

Duplicate comments:
In
`@netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html`:
- Around line 575-579: Update the include in librenms_sync_base.html that
currently references '_module_sync.html' so it points to the reusable partial
under the inc subfolder (i.e., change the include from
'netbox_librenms_plugin/_module_sync.html' to
'netbox_librenms_plugin/inc/_module_sync.html'), and move the
'_module_sync.html' partial file into the inc/ directory so the template loader
finds it; ensure any other references use the new inc/_module_sync.html path.

In `@netbox_librenms_plugin/views/base/modules_view.py`:
- Around line 240-241: The code calls cache.ttl(...) unguarded which will raise
on backends lacking ttl; update the logic around cache_ttl =
cache.ttl(self.get_cache_key(obj, "inventory")) in the modules view to first
check for the existence of ttl (e.g., use hasattr(cache, "ttl") or
getattr(cache, "ttl", None)) and only call it when present, falling back to None
otherwise, then compute cache_expiry = timezone.now() +
timezone.timedelta(seconds=cache_ttl) if cache_ttl is not None else None; refer
to the cache_ttl variable and the get_cache_key(self, obj, "inventory") call to
locate where to add the guard.
- Around line 512-519: The captured slot value expected_fpc (from
match.group(1)) is currently a string and is being compared to
parent_bay.position (numeric), causing mismatches; update the _fpc_slot_matches
logic so that after obtaining expected_fpc you coerce it to int (e.g., parse
int(match.group(1))) and handle parse errors by returning False (or falling back
appropriately) before comparing to parent_bay.position; locate the code around
expected_fpc, bay, module, and parent_bay (module.module_bay) to apply this
change.

ℹ️ Review info

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

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between e1381e2 and b466553.

📒 Files selected for processing (19)
  • .devcontainer/scripts/diagnose.sh
  • .devcontainer/scripts/setup.sh
  • .devcontainer/scripts/start-netbox.sh
  • .devcontainer/scripts/welcome.sh
  • .github/workflows/test.yaml
  • netbox_librenms_plugin/__init__.py
  • netbox_librenms_plugin/forms.py
  • netbox_librenms_plugin/migrations/0013_normalizationrule.py
  • netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html
  • netbox_librenms_plugin/tests/test_init.py
  • netbox_librenms_plugin/tests/test_utils.py
  • netbox_librenms_plugin/urls.py
  • netbox_librenms_plugin/utils.py
  • netbox_librenms_plugin/views/__init__.py
  • netbox_librenms_plugin/views/base/cables_view.py
  • netbox_librenms_plugin/views/base/modules_view.py
  • netbox_librenms_plugin/views/imports/actions.py
  • netbox_librenms_plugin/views/sync/cables.py
  • netbox_librenms_plugin/views/sync/interfaces.py
📜 Review details
🧰 Additional context used
📓 Path-based instructions (5)
**/*.py

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

**/*.py: Reuse the librenms_api.py LibreNMS client instead of making new requests calls; it handles multi-server configs via LibreNMSSettings model and the servers plugin config, plus caching
Always call LibreNMSAPI.get_librenms_id to retrieve the device/VM LibreNMS mapping via the librenms_id custom field instead of touching the field directly
Matching must be exact-only for site, platform, device type, and role; do not add fuzzy matching; use the functions find_matching_site, match_librenms_hardware_to_device_type, and find_matching_platform from utils.py

Files:

  • netbox_librenms_plugin/views/base/cables_view.py
  • netbox_librenms_plugin/views/imports/actions.py
  • netbox_librenms_plugin/views/sync/interfaces.py
  • netbox_librenms_plugin/views/sync/cables.py
  • netbox_librenms_plugin/__init__.py
  • netbox_librenms_plugin/utils.py
  • netbox_librenms_plugin/tests/test_utils.py
  • netbox_librenms_plugin/tests/test_init.py
  • netbox_librenms_plugin/forms.py
  • netbox_librenms_plugin/migrations/0013_normalizationrule.py
  • netbox_librenms_plugin/views/base/modules_view.py
  • netbox_librenms_plugin/views/__init__.py
  • netbox_librenms_plugin/urls.py
**/views/imports/**

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

**/views/imports/**: Use Job UUID (job.job_id) for RQ API endpoints: /api/core/background-tasks/{uuid}/
Use Job PK (job.pk) for database endpoints and result loading
Check rq_job.is_stopped or rq_job.is_failed flags in Redis for cancellation detection, not database status
Call /api/core/background-tasks/{uuid}/stop/ to stop RQ job in the job cancellation flow
Call plugin's sync endpoint /api/plugins/librenms_plugin/jobs/{pk}/sync-status/ to update database after stopping RQ job
Poll /api/core/background-tasks/{uuid}/ for real-time RQ status instead of polling the database
api/views.py::sync_job_status() must sync database Job status with RQ job status because NetBox worker doesn't always update DB when jobs stop before processing starts

Files:

  • netbox_librenms_plugin/views/imports/actions.py
netbox_librenms_plugin/templates/**/*.html

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

netbox_librenms_plugin/templates/**/*.html: HTMX 2.x is the primary async layer. Table row updates should return <tr hx-swap-oob="true">.
Avoid outerHTML swaps in HTMX; use OOB or targeted innerHTML swaps to keep table layout intact.
Modals use Tabler (Bootstrap-like) but without bootstrap.Modal helpers.
Modal buttons should target the htmx-modal-content element. JavaScript in librenms_import.html toggles the wrapper; do not reintroduce data-bs-toggle or duplicate modal IDs.
Keep <select class="device-role-select"> markup stable to preserve JavaScript hook-up with TomSelect decorators.
Styling assumes Tabler defaults. Do not add table-responsive wrappers as they were deliberately removed to prevent dropdown clipping.

Files:

  • netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html
netbox_librenms_plugin/templates/netbox_librenms_plugin/**/*.html

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

Templates should live in templates/netbox_librenms_plugin/. Reusable template includes should be placed under inc/ subdirectory.

Files:

  • netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html
netbox_librenms_plugin/templates/netbox_librenms_plugin/*sync*.html

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

Sync pages should extend librenms_sync_base.html.

Files:

  • netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html
🧠 Learnings (25)
📓 Common learnings
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-02-15T15:38:54.189Z
Learning: Follow sync pipeline flow: fetch LibreNMS data via `librenms_api.py`, cache it using `CacheMixin`, build comparison tables in `tables/`, and render HTMX fragments in `templates/netbox_librenms_plugin/htmx/`
📚 Learning: 2026-02-15T15:38:54.189Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-02-15T15:38:54.189Z
Learning: Use devcontainer commands (`netbox-run`, `netbox-run-bg`, `netbox-reload`, `netbox-logs`) to manage NetBox + plugin reloading during development

Applied to files:

  • .devcontainer/scripts/diagnose.sh
  • .devcontainer/scripts/start-netbox.sh
  • .devcontainer/scripts/welcome.sh
  • .devcontainer/scripts/setup.sh
📚 Learning: 2026-02-15T15:39:11.578Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/frontend.instructions.md:0-0
Timestamp: 2026-02-15T15:39:11.578Z
Learning: Applies to netbox_librenms_plugin/templates/**/*.html : Avoid `outerHTML` swaps in HTMX; use OOB or targeted `innerHTML` swaps to keep table layout intact.

Applied to files:

  • netbox_librenms_plugin/views/base/cables_view.py
  • netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html
📚 Learning: 2026-02-15T15:38:54.189Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-02-15T15:38:54.189Z
Learning: Applies to views/imports/**/*.py : REST endpoints for imports live in `views/imports/actions.py` with the list view in `views/imports/list.py`, surface via `urls.py`, and emit HTMX fragments in `templates/netbox_librenms_plugin/htmx/`; keep server responses and HTMX targets in sync

Applied to files:

  • netbox_librenms_plugin/views/imports/actions.py
  • netbox_librenms_plugin/forms.py
  • netbox_librenms_plugin/views/base/modules_view.py
  • netbox_librenms_plugin/views/__init__.py
  • netbox_librenms_plugin/urls.py
📚 Learning: 2026-02-15T15:38:54.189Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-02-15T15:38:54.189Z
Learning: Follow sync pipeline flow: fetch LibreNMS data via `librenms_api.py`, cache it using `CacheMixin`, build comparison tables in `tables/`, and render HTMX fragments in `templates/netbox_librenms_plugin/htmx/`

Applied to files:

  • netbox_librenms_plugin/views/imports/actions.py
  • netbox_librenms_plugin/forms.py
  • netbox_librenms_plugin/views/base/modules_view.py
  • netbox_librenms_plugin/views/__init__.py
  • netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html
📚 Learning: 2026-02-15T15:39:11.578Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/frontend.instructions.md:0-0
Timestamp: 2026-02-15T15:39:11.578Z
Learning: Applies to netbox_librenms_plugin/templates/netbox_librenms_plugin/*sync*.html : Sync pages should extend `librenms_sync_base.html`.

Applied to files:

  • netbox_librenms_plugin/views/imports/actions.py
  • netbox_librenms_plugin/views/sync/interfaces.py
  • netbox_librenms_plugin/views/base/modules_view.py
  • netbox_librenms_plugin/views/__init__.py
  • netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html
📚 Learning: 2026-02-15T15:39:03.405Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/background-jobs.instructions.md:0-0
Timestamp: 2026-02-15T15:39:03.405Z
Learning: Applies to **/views/imports/** : `api/views.py::sync_job_status()` must sync database Job status with RQ job status because NetBox worker doesn't always update DB when jobs stop before processing starts

Applied to files:

  • netbox_librenms_plugin/views/imports/actions.py
📚 Learning: 2026-02-15T15:38:54.189Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-02-15T15:38:54.189Z
Learning: Applies to **/*.py : Matching must be exact-only for site, platform, device type, and role; do not add fuzzy matching; use the functions `find_matching_site`, `match_librenms_hardware_to_device_type`, and `find_matching_platform` from `utils.py`

Applied to files:

  • netbox_librenms_plugin/views/imports/actions.py
  • netbox_librenms_plugin/utils.py
  • netbox_librenms_plugin/tests/test_utils.py
📚 Learning: 2026-02-15T15:38:54.189Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-02-15T15:38:54.189Z
Learning: Applies to **/*.py : Always call `LibreNMSAPI.get_librenms_id` to retrieve the device/VM LibreNMS mapping via the `librenms_id` custom field instead of touching the field directly

Applied to files:

  • netbox_librenms_plugin/__init__.py
📚 Learning: 2026-02-15T15:39:20.744Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/testing.instructions.md:0-0
Timestamp: 2026-02-15T15:39:20.744Z
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/__init__.py
  • netbox_librenms_plugin/tests/test_utils.py
  • netbox_librenms_plugin/tests/test_init.py
  • netbox_librenms_plugin/urls.py
📚 Learning: 2026-02-15T15:39:20.744Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/testing.instructions.md:0-0
Timestamp: 2026-02-15T15:39:20.744Z
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_utils.py
  • netbox_librenms_plugin/tests/test_init.py
📚 Learning: 2026-02-15T15:39:20.744Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/testing.instructions.md:0-0
Timestamp: 2026-02-15T15:39:20.744Z
Learning: Applies to tests/**/*.py : Test coverage mapping: `librenms_api.py` → `test_librenms_api.py`, `import_utils.py`/`import_validation_helpers.py`/`utils.py` → `test_import_utils.py`/`test_import_validation_helpers.py`/`test_utils.py`, `jobs.py`/`views/imports/list.py` → `test_background_jobs.py`

Applied to files:

  • netbox_librenms_plugin/tests/test_utils.py
  • netbox_librenms_plugin/tests/test_init.py
📚 Learning: 2026-02-15T15:39:20.744Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/testing.instructions.md:0-0
Timestamp: 2026-02-15T15:39:20.744Z
Learning: Applies to tests/test_import_utils.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_utils.py
📚 Learning: 2026-02-15T15:39:20.744Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/testing.instructions.md:0-0
Timestamp: 2026-02-15T15:39:20.744Z
Learning: Applies to tests/test_background_jobs.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_utils.py
  • netbox_librenms_plugin/views/base/modules_view.py
📚 Learning: 2026-02-15T15:38:54.189Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-02-15T15:38:54.189Z
Learning: Applies to {navigation.py,urls.py,api/**} : Respect NetBox plugin APIs in `navigation.py`, `urls.py`, and `api/` directories

Applied to files:

  • netbox_librenms_plugin/forms.py
  • netbox_librenms_plugin/views/base/modules_view.py
  • netbox_librenms_plugin/views/__init__.py
  • netbox_librenms_plugin/urls.py
📚 Learning: 2026-02-15T15:39:11.578Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/frontend.instructions.md:0-0
Timestamp: 2026-02-15T15:39:11.578Z
Learning: Applies to netbox_librenms_plugin/templates/**/*.html : Keep `<select class="device-role-select">` markup stable to preserve JavaScript hook-up with TomSelect decorators.

Applied to files:

  • netbox_librenms_plugin/forms.py
  • netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html
📚 Learning: 2026-02-15T15:38:54.189Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-02-15T15:38:54.189Z
Learning: Applies to **/*.py : Reuse the `librenms_api.py` LibreNMS client instead of making new `requests` calls; it handles multi-server configs via `LibreNMSSettings` model and the `servers` plugin config, plus caching

Applied to files:

  • netbox_librenms_plugin/forms.py
📚 Learning: 2026-02-15T15:38:54.189Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-02-15T15:38:54.189Z
Learning: Applies to templates/**/*.html : Reuse template includes under `templates/netbox_librenms_plugin/inc/` and ensure sync pages extend `librenms_sync_base.html`

Applied to files:

  • netbox_librenms_plugin/views/base/modules_view.py
  • netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html
📚 Learning: 2026-02-15T15:38:54.189Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-02-15T15:38:54.189Z
Learning: Applies to views/**/*.py : Views should follow layered structure: extend the closest base class from `views/base/` and compose mixins like `LibreNMSAPIMixin` and `CacheMixin`

Applied to files:

  • netbox_librenms_plugin/views/base/modules_view.py
  • netbox_librenms_plugin/views/__init__.py
📚 Learning: 2026-02-15T15:39:11.578Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/frontend.instructions.md:0-0
Timestamp: 2026-02-15T15:39:11.578Z
Learning: Applies to netbox_librenms_plugin/templates/**/*.html : HTMX 2.x is the primary async layer. Table row updates should return `<tr hx-swap-oob="true">`.

Applied to files:

  • netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html
📚 Learning: 2026-02-15T15:39:11.578Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/frontend.instructions.md:0-0
Timestamp: 2026-02-15T15:39:11.578Z
Learning: Applies to netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/**/*.html : HTMX fragments should live in `templates/netbox_librenms_plugin/htmx/`. Keep server responses and HTMX targets in sync.

Applied to files:

  • netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html
📚 Learning: 2026-02-15T15:39:11.578Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/frontend.instructions.md:0-0
Timestamp: 2026-02-15T15:39:11.578Z
Learning: Applies to netbox_librenms_plugin/templates/**/*.html : Styling assumes Tabler defaults. Do not add `table-responsive` wrappers as they were deliberately removed to prevent dropdown clipping.

Applied to files:

  • netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html
📚 Learning: 2026-02-15T15:39:11.578Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/frontend.instructions.md:0-0
Timestamp: 2026-02-15T15:39:11.578Z
Learning: Applies to netbox_librenms_plugin/templates/**/*.html : Modal buttons should target the `htmx-modal-content` element. 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-02-15T15:39:11.578Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/frontend.instructions.md:0-0
Timestamp: 2026-02-15T15:39:11.578Z
Learning: Applies to netbox_librenms_plugin/templates/netbox_librenms_plugin/**/*.html : Templates should live in `templates/netbox_librenms_plugin/`. Reusable template includes should be placed under `inc/` subdirectory.

Applied to files:

  • netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html
📚 Learning: 2026-02-15T15:39:11.578Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/frontend.instructions.md:0-0
Timestamp: 2026-02-15T15:39:11.578Z
Learning: Applies to netbox_librenms_plugin/templates/**/*.html : Modals use Tabler (Bootstrap-like) but without `bootstrap.Modal` helpers.

Applied to files:

  • netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html
🔇 Additional comments (27)
.devcontainer/scripts/welcome.sh (2)

2-2: Good standardization with script header.

Clear file identification improves maintainability with no behavior change.


47-47: Nice UX text correction.

The wording fix improves terminal guidance clarity for local setup users.

.devcontainer/scripts/start-netbox.sh (3)

2-2: Header addition looks good.

Consistent script labeling helps navigation and maintenance.


29-31: Graceful shutdown fallback is a solid reliability improvement.

Using SIGTERM with a timed fallback to SIGKILL reduces abrupt worker termination risk.


39-41: Consistent process cleanup strategy for NetBox server.

Applying the same TERM-then-KILL pattern here is correct and keeps restart behavior predictable.

.devcontainer/scripts/diagnose.sh (2)

2-2: Script header update is good.

This keeps devcontainer scripts consistently self-described.


41-41: Defensive PID validation is correct.

The non-empty guard plus quoting makes the health check safer and less error-prone.

.devcontainer/scripts/setup.sh (2)

2-2: Header standardization looks good.

Keeps script metadata consistent across the devcontainer tooling set.


263-263: Good security hardening in setup logs.

Removing password output from the success message reduces credential leakage risk.

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

240-246: LGTM - Admin status propagation logic is sound.

The conditional correctly handles the three cases: None (defaults to enabled), string values (case-insensitive "up" check), and boolean/truthy values. The exclude_columns gating follows the same pattern as other attributes.


237-238: No action needed—the code is correct.

The coding guideline "Always call LibreNMSAPI.get_librenms_id to retrieve the device/VM LibreNMS mapping" applies specifically to retrieving device/VM mappings for lookups and sync operations. This code is writing the interface's port_id to its custom field after sync.

Additionally, _store_librenms_id() is explicitly designed for "NetBox device or VM objects" (per its docstring), not interfaces. There is no interface-level setter utility. Direct custom field assignment is the standard and correct pattern for interface-level custom fields.

.github/workflows/test.yaml (3)

54-56: Explicit ref: main for NetBox checkout is appropriate.

Pinning to main makes the checkout behavior explicit rather than relying on default branch resolution. Be aware that testing against NetBox's main branch means CI could break due to upstream changes—this is acceptable if the goal is to catch compatibility issues early.


42-42: SHA pinning for GitHub Actions is correctly implemented.

The actions are now pinned to full commit SHAs with version comments, which improves supply-chain security and CI reproducibility. Python 3.14 is stable and supported as of the current date (February 2026).


19-19: Python 3.14 is available on ubuntu-latest runners.

Python 3.14.0 was released on October 7, 2025, and current ubuntu-latest (Ubuntu 24.04) runners in February 2026 include Python 3.14.x in their cached tools. The test matrix is correct and will not encounter availability issues.

netbox_librenms_plugin/views/base/cables_view.py (1)

55-55: Per-request interface-name resolution looks correct.

This avoids stale class-level state and safely handles request-less contexts.

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

252-263: Good guard against placeholder sysName values.

The sync action is now correctly hidden for "-" placeholder values, preventing accidental name overwrites.

netbox_librenms_plugin/utils.py (1)

531-575: Normalization pipeline hardening looks solid.

Catching re.error per rule prevents one bad DB rule from crashing the full normalization chain.

netbox_librenms_plugin/tests/test_utils.py (1)

74-89: Good addition: mapping-priority test coverage.

This verifies DeviceTypeMapping takes precedence over part/model fallback as intended.

netbox_librenms_plugin/migrations/0013_normalizationrule.py (1)

23-93: SeparateDatabaseAndState usage is appropriate here.

The migration cleanly restores ORM state while safely handling fresh installs via guarded SQL creation.

netbox_librenms_plugin/views/base/modules_view.py (1)

794-799: Good defensive handling for invalid parent_index.

This now prevents ValueError from surfacing as a 500 and keeps the flow user-friendly.

netbox_librenms_plugin/urls.py (1)

109-123: Route wiring for module sync/conflicts and new mapping resources looks consistent.

The added endpoints are clearly namespaced and align with the expanded view/model surface.

Also applies to: 319-323, 388-555

netbox_librenms_plugin/forms.py (3)

62-106: LGTM on the server-scoped cache key fix.

The poller group cache key now correctly includes the server identifier (api.librenms_url) to avoid returning stale data when switching between LibreNMS servers.


299-454: LGTM on new mapping forms.

The new DeviceTypeMapping, ModuleTypeMapping, ModuleBayMapping, and NormalizationRule forms follow NetBox conventions correctly, with proper use of DynamicModelChoiceField, CSVChoiceField, and CSVModelChoiceField for their respective form types.


833-956: LGTM on DeviceImportConfigForm.

The form properly handles dynamic initialization with LibreNMS device data and validation results. The deferred Platform import avoids circular dependencies, and the queryset filtering for suggested device types is correctly implemented.

netbox_librenms_plugin/views/__init__.py (1)

1-97: LGTM on view exports.

The new view re-exports follow the established pattern and correctly expose the new mapping views and action views for use throughout the plugin.

netbox_librenms_plugin/__init__.py (1)

41-45: LGTM on signal connection.

The post_migrate signal connection with dispatch_uid correctly ensures the handler runs once per migration process without duplicate connections.

netbox_librenms_plugin/tests/test_init.py (1)

14-171: LGTM on test suite.

The test suite comprehensively covers the _ensure_librenms_id_custom_field handler: creation with correct defaults, deduplication via _executed flag, content type management, exception handling, and logging behavior. The mocking strategy follows testing guidelines.

Comment thread netbox_librenms_plugin/__init__.py Outdated
Comment thread netbox_librenms_plugin/views/base/cables_view.py Outdated
Comment thread netbox_librenms_plugin/views/imports/actions.py Outdated
Comment thread netbox_librenms_plugin/views/imports/actions.py Outdated
Comment thread netbox_librenms_plugin/views/sync/cables.py Outdated

ghost 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: 9

♻️ Duplicate comments (2)
netbox_librenms_plugin/views/imports/actions.py (1)

822-843: ⚠️ Potential issue | 🔴 Critical

Enforce object-level authorization and bind mutations to the validated conflict target.

Line 830/Line 836 trusts existing_device_id from POST, and mutations then apply to that object. This still allows crafted requests to target unintended Device records unless you bind to validation["existing_device"] and authorize that specific object before mutation.

🛡️ Proposed hardening
-        action = request.POST.get("action")
-        existing_device_id = request.POST.get("existing_device_id")
+        action = request.POST.get("action")
+        existing_device_id = request.POST.get("existing_device_id")

         if not action or not existing_device_id:
             return HttpResponse("Missing action or existing_device_id", status=400)

-        try:
-            existing_device = Device.objects.get(pk=int(existing_device_id))
-        except (Device.DoesNotExist, ValueError):
-            return HttpResponse("Existing device not found", status=404)
-
         libre_device, validation, selections = self.get_validated_device_with_selections(device_id, request)
         if not libre_device:
             return HttpResponse("LibreNMS device not found", status=404)
+
+        expected_existing = validation.get("existing_device")
+        if not expected_existing or str(expected_existing.pk) != str(existing_device_id):
+            return HttpResponse("existing_device_id does not match detected conflict target", status=400)
+
+        existing_device = expected_existing
+        if not request.user.has_perm("dcim.change_device", existing_device):
+            return HttpResponse("Permission denied", status=403)
🤖 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 822 - 843,
Don’t trust POST existing_device_id directly: use the validated target returned
by get_validated_device_with_selections (validation["existing_device"]) instead
of Device.objects.get(pk=existing_device_id), and perform an object-level
authorization check on that validated Device before any mutations (i.e. call the
view’s object-level permission routine rather than only require_write_permission
global check). Replace uses of existing_device derived from existing_device_id
with validation["existing_device"] and invoke the appropriate object permission
check (for example a check_object_permissions /
require_write_permission_on_object-style call) on that object prior to applying
link/update/sync operations.
netbox_librenms_plugin/views/base/modules_view.py (1)

512-519: ⚠️ Potential issue | 🟠 Major

Cast extracted FPC slot before comparing with ModuleBay.position.

match.group(1) is a string, so the Line 519 comparison can fail for valid matches when position is numeric.

🔧 Proposed fix
-        expected_fpc = match.group(1)
+        try:
+            expected_fpc = int(match.group(1))
+        except (TypeError, ValueError):
+            return True
@@
-        return parent_bay.position == expected_fpc
+        return parent_bay.position == expected_fpc
In current NetBox (dcim.models.ModuleBay), what is the type of the `position` field?
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@netbox_librenms_plugin/views/base/modules_view.py` around lines 512 - 519,
The extracted expected_fpc is a string (from match.group(1)) but
ModuleBay.position is numeric, so update the logic in the block using
expected_fpc/module/parent_bay to cast expected_fpc to an integer before
comparing to parent_bay.position (e.g., convert match.group(1) to int, handling
ValueError by returning False or True per current behavior); specifically modify
where expected_fpc is set and used in the function that accesses bay, module,
parent_bay and returns parent_bay.position == expected_fpc so the comparison
uses a numeric expected_fpc.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In @.devcontainer/scripts/diagnose.sh:
- Line 35: The diagnostic echo is checking the wrong relative path for the
plugin config; update the path check in the echo that references PLUGIN_WS_DIR
so it looks for " .devcontainer/config/plugin-config.py " (the config
subdirectory used by the scripts) instead of " .devcontainer/plugin-config.py ",
and keep the same conditional pattern using test -f "$PLUGIN_WS_DIR/..." so the
message correctly reports Found vs Missing (using defaults).

In @.devcontainer/scripts/load-aliases.sh:
- Around line 93-129: The netbox-status and rq-status functions currently only
check PID files and thus can falsely report services as not running; update both
to fall back to searching the process table when the PID file is missing or the
PID doesn't match is_expected_pid. Specifically, in netbox-status (function name
netbox-status) and rq-status (function name rq-status) keep the PID-file checks
but add a fallback that uses pgrep -f or ps -ef | grep -E to look for processes
matching the same patterns used by is_expected_pid ("python.*runserver.*8000"
for NetBox and "python.*rqworker" for RQ), capture and report any found PID(s)
and their state, and only report "not running" when neither the PID file nor the
process-table search finds a matching process. Ensure the new logic preserves
existing calls to is_expected_pid and handles multiple PIDs gracefully (report
list or first PID).

In @.devcontainer/scripts/process-helpers.sh:
- Around line 6-11: The graceful_kill_pid function must re-validate that the PID
still corresponds to the original process before issuing SIGKILL to avoid
killing a recycled PID; modify graceful_kill_pid to either call the existing
is_expected_pid check internally (or accept an expected identifier like the
process start time/command) and verify it after the sleep and before kill -9, or
read /proc/<pid>/stat (or use ps) to compare start time/command for the PID and
only send SIGKILL if the identity matches; ensure you reference and use
graceful_kill_pid and is_expected_pid (or the proc-stat check) so the final kill
-9 is conditional on the re-validation.

In `@netbox_librenms_plugin/__init__.py`:
- Around line 82-85: The current early assignment
_ensure_librenms_id_custom_field._executed = True happens before the DB work so
any exception prevents the custom field from being created while blocking
retries; move the _executed assignment to after the function completes
successfully (after the DB operations/commit) inside
_ensure_librenms_id_custom_field so it only marks execution on success, and
apply the same change to the other identical pattern in the file (the block at
the other ensure function / the second _executed assignment).

In `@netbox_librenms_plugin/views/base/modules_view.py`:
- Around line 93-96: The cache check in modules_view treats falsy values (like
empty lists) as cache misses; change the conditional that inspects cached_data
(from cache.get(self.get_cache_key(obj, "inventory"))) to explicitly check for
None (e.g., if cached_data is None) so an empty inventory list is treated as a
valid cache hit and still passed into _build_context(request, obj, cached_data);
keep the early return only for None results.
- Around line 994-1015: The parent-module lookup in _find_parent_module_id
excludes module-scoped bays by filtering ModuleBay with module_id__isnull=True;
remove that restriction (or also query without module_id__isnull) so bays
installed inside other modules are considered when checking bay.name ==
parent_name/parent_descr and when resolving mapping.netbox_bay_name; also
tighten the ModuleBayMapping lookup to only exact matches by adding
is_regex=False to the filter (i.e.,
ModuleBayMapping.objects.filter(librenms_name=name, is_regex=False).first()) so
mapping resolution matches the rest of the codebase.

In `@netbox_librenms_plugin/views/imports/actions.py`:
- Around line 887-889: The HTMX error responses interpolate untrusted values
(incoming_serial, conflict_device.name, action) directly into HTML, which can
lead to XSS; fix by escaping those values before embedding them into response
content (use django.utils.html.escape or build the message with
django.utils.html.format_html) wherever the f-strings are used (the responses
around the serial conflict and the other error messages at the locations
referencing incoming_serial, conflict_device.name, and action), e.g., replace
direct f-string interpolation with escaped_incoming = escape(incoming_serial),
escaped_name = escape(conflict_device.name) (or use format_html for safe
concatenation) and then return the HttpResponse/JsonResponse with the escaped
content and the same status codes.

In `@netbox_librenms_plugin/views/sync/cables.py`:
- Around line 128-137: The loop that syncs interfaces currently treats all
unexpected exceptions as "invalid" which conflates real validation errors with
runtime failures; modify the results dict to include a distinct category (e.g.,
"error" or "failed") and change the exception handler in the selected_interfaces
loop to append interface.get("interface","") to results["error"] instead of
results["invalid"], while keeping the logger.exception call for the stack trace;
ensure any downstream code that reads results (or user-facing messages) is
updated to handle the new "error" key so users see a clear distinction between
invalid link data and internal runtime errors.

In `@netbox_librenms_plugin/views/sync/interfaces.py`:
- Around line 242-244: The MAC-address sync must be guarded by the same
device-interface check as the ifType code to avoid AttributeError on
virtualization.VMInterface; modify the block that checks "mac_address" and calls
handle_mac_address(interface, ifPhysAddress) to only run when
is_device_interface(interface) is true (i.e., wrap the existing exclude_columns
check and handle_mac_address call with an is_device_interface(interface) guard
or combine both conditions), using the existing is_device_interface helper and
keeping the ifPhysAddress variable and handle_mac_address invocation as-is.

---

Duplicate comments:
In `@netbox_librenms_plugin/views/base/modules_view.py`:
- Around line 512-519: The extracted expected_fpc is a string (from
match.group(1)) but ModuleBay.position is numeric, so update the logic in the
block using expected_fpc/module/parent_bay to cast expected_fpc to an integer
before comparing to parent_bay.position (e.g., convert match.group(1) to int,
handling ValueError by returning False or True per current behavior);
specifically modify where expected_fpc is set and used in the function that
accesses bay, module, parent_bay and returns parent_bay.position == expected_fpc
so the comparison uses a numeric expected_fpc.

In `@netbox_librenms_plugin/views/imports/actions.py`:
- Around line 822-843: Don’t trust POST existing_device_id directly: use the
validated target returned by get_validated_device_with_selections
(validation["existing_device"]) instead of
Device.objects.get(pk=existing_device_id), and perform an object-level
authorization check on that validated Device before any mutations (i.e. call the
view’s object-level permission routine rather than only require_write_permission
global check). Replace uses of existing_device derived from existing_device_id
with validation["existing_device"] and invoke the appropriate object permission
check (for example a check_object_permissions /
require_write_permission_on_object-style call) on that object prior to applying
link/update/sync operations.

ℹ️ Review info

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

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between b466553 and bf3ad64.

📒 Files selected for processing (15)
  • .devcontainer/scripts/diagnose.sh
  • .devcontainer/scripts/load-aliases.sh
  • .devcontainer/scripts/process-helpers.sh
  • .devcontainer/scripts/setup.sh
  • .devcontainer/scripts/start-netbox.sh
  • .devcontainer/scripts/welcome.sh
  • netbox_librenms_plugin/__init__.py
  • netbox_librenms_plugin/forms.py
  • netbox_librenms_plugin/templates/netbox_librenms_plugin/inc/_module_sync.html
  • netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html
  • netbox_librenms_plugin/views/base/cables_view.py
  • netbox_librenms_plugin/views/base/modules_view.py
  • netbox_librenms_plugin/views/imports/actions.py
  • netbox_librenms_plugin/views/sync/cables.py
  • netbox_librenms_plugin/views/sync/interfaces.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.13)
  • GitHub Check: test-netbox (3.14)
🧰 Additional context used
📓 Path-based instructions (5)
netbox_librenms_plugin/templates/**/*.html

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

netbox_librenms_plugin/templates/**/*.html: HTMX 2.x is the primary async layer. Table row updates should return <tr hx-swap-oob="true">.
Avoid outerHTML swaps in HTMX; use OOB or targeted innerHTML swaps to keep table layout intact.
Modals use Tabler (Bootstrap-like) but without bootstrap.Modal helpers.
Modal buttons should target the htmx-modal-content element. JavaScript in librenms_import.html toggles the wrapper; do not reintroduce data-bs-toggle or duplicate modal IDs.
Keep <select class="device-role-select"> markup stable to preserve JavaScript hook-up with TomSelect decorators.
Styling assumes Tabler defaults. Do not add table-responsive wrappers as they were deliberately removed to prevent dropdown clipping.

Files:

  • netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html
  • netbox_librenms_plugin/templates/netbox_librenms_plugin/inc/_module_sync.html
netbox_librenms_plugin/templates/netbox_librenms_plugin/**/*.html

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

Templates should live in templates/netbox_librenms_plugin/. Reusable template includes should be placed under inc/ subdirectory.

Files:

  • netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html
  • netbox_librenms_plugin/templates/netbox_librenms_plugin/inc/_module_sync.html
netbox_librenms_plugin/templates/netbox_librenms_plugin/*sync*.html

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

Sync pages should extend librenms_sync_base.html.

Files:

  • netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html
**/*.py

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

**/*.py: Reuse the librenms_api.py LibreNMS client instead of making new requests calls; it handles multi-server configs via LibreNMSSettings model and the servers plugin config, plus caching
Always call LibreNMSAPI.get_librenms_id to retrieve the device/VM LibreNMS mapping via the librenms_id custom field instead of touching the field directly
Matching must be exact-only for site, platform, device type, and role; do not add fuzzy matching; use the functions find_matching_site, match_librenms_hardware_to_device_type, and find_matching_platform from utils.py

Files:

  • netbox_librenms_plugin/views/base/cables_view.py
  • netbox_librenms_plugin/views/base/modules_view.py
  • netbox_librenms_plugin/views/sync/interfaces.py
  • netbox_librenms_plugin/views/sync/cables.py
  • netbox_librenms_plugin/__init__.py
  • netbox_librenms_plugin/views/imports/actions.py
  • netbox_librenms_plugin/forms.py
**/views/imports/**

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

**/views/imports/**: Use Job UUID (job.job_id) for RQ API endpoints: /api/core/background-tasks/{uuid}/
Use Job PK (job.pk) for database endpoints and result loading
Check rq_job.is_stopped or rq_job.is_failed flags in Redis for cancellation detection, not database status
Call /api/core/background-tasks/{uuid}/stop/ to stop RQ job in the job cancellation flow
Call plugin's sync endpoint /api/plugins/librenms_plugin/jobs/{pk}/sync-status/ to update database after stopping RQ job
Poll /api/core/background-tasks/{uuid}/ for real-time RQ status instead of polling the database
api/views.py::sync_job_status() must sync database Job status with RQ job status because NetBox worker doesn't always update DB when jobs stop before processing starts

Files:

  • netbox_librenms_plugin/views/imports/actions.py
🧠 Learnings (22)
📓 Common learnings
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-02-15T15:38:54.189Z
Learning: Follow sync pipeline flow: fetch LibreNMS data via `librenms_api.py`, cache it using `CacheMixin`, build comparison tables in `tables/`, and render HTMX fragments in `templates/netbox_librenms_plugin/htmx/`
📚 Learning: 2026-02-15T15:38:54.189Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-02-15T15:38:54.189Z
Learning: Use devcontainer commands (`netbox-run`, `netbox-run-bg`, `netbox-reload`, `netbox-logs`) to manage NetBox + plugin reloading during development

Applied to files:

  • .devcontainer/scripts/start-netbox.sh
  • .devcontainer/scripts/diagnose.sh
  • .devcontainer/scripts/welcome.sh
  • .devcontainer/scripts/load-aliases.sh
  • .devcontainer/scripts/setup.sh
📚 Learning: 2026-02-15T15:39:11.578Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/frontend.instructions.md:0-0
Timestamp: 2026-02-15T15:39:11.578Z
Learning: Applies to netbox_librenms_plugin/templates/netbox_librenms_plugin/*sync*.html : Sync pages should extend `librenms_sync_base.html`.

Applied to files:

  • netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html
  • netbox_librenms_plugin/views/base/modules_view.py
  • netbox_librenms_plugin/views/sync/interfaces.py
  • netbox_librenms_plugin/templates/netbox_librenms_plugin/inc/_module_sync.html
  • netbox_librenms_plugin/views/imports/actions.py
📚 Learning: 2026-02-15T15:38:54.189Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-02-15T15:38:54.189Z
Learning: Applies to templates/**/*.html : Reuse template includes under `templates/netbox_librenms_plugin/inc/` and ensure sync pages extend `librenms_sync_base.html`

Applied to files:

  • netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html
  • netbox_librenms_plugin/views/base/modules_view.py
  • netbox_librenms_plugin/templates/netbox_librenms_plugin/inc/_module_sync.html
📚 Learning: 2026-02-15T15:39:11.578Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/frontend.instructions.md:0-0
Timestamp: 2026-02-15T15:39:11.578Z
Learning: Applies to netbox_librenms_plugin/templates/**/*.html : Avoid `outerHTML` swaps in HTMX; use OOB or targeted `innerHTML` swaps to keep table layout intact.

Applied to files:

  • netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html
  • netbox_librenms_plugin/views/base/cables_view.py
  • netbox_librenms_plugin/templates/netbox_librenms_plugin/inc/_module_sync.html
📚 Learning: 2026-02-15T15:39:11.578Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/frontend.instructions.md:0-0
Timestamp: 2026-02-15T15:39:11.578Z
Learning: Applies to netbox_librenms_plugin/templates/**/*.html : HTMX 2.x is the primary async layer. Table row updates should return `<tr hx-swap-oob="true">`.

Applied to files:

  • netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html
  • netbox_librenms_plugin/templates/netbox_librenms_plugin/inc/_module_sync.html
📚 Learning: 2026-02-15T15:39:11.578Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/frontend.instructions.md:0-0
Timestamp: 2026-02-15T15:39:11.578Z
Learning: Applies to netbox_librenms_plugin/templates/**/*.html : Keep `<select class="device-role-select">` markup stable to preserve JavaScript hook-up with TomSelect decorators.

Applied to files:

  • netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html
  • netbox_librenms_plugin/forms.py
📚 Learning: 2026-02-15T15:38:54.189Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-02-15T15:38:54.189Z
Learning: Follow sync pipeline flow: fetch LibreNMS data via `librenms_api.py`, cache it using `CacheMixin`, build comparison tables in `tables/`, and render HTMX fragments in `templates/netbox_librenms_plugin/htmx/`

Applied to files:

  • netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html
  • netbox_librenms_plugin/views/base/modules_view.py
  • netbox_librenms_plugin/templates/netbox_librenms_plugin/inc/_module_sync.html
  • netbox_librenms_plugin/views/imports/actions.py
  • netbox_librenms_plugin/forms.py
📚 Learning: 2026-02-15T15:39:11.578Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/frontend.instructions.md:0-0
Timestamp: 2026-02-15T15:39:11.578Z
Learning: Applies to netbox_librenms_plugin/templates/**/*.html : Styling assumes Tabler defaults. Do not add `table-responsive` wrappers as they were deliberately removed to prevent dropdown clipping.

Applied to files:

  • netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html
📚 Learning: 2026-02-15T15:39:11.578Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/frontend.instructions.md:0-0
Timestamp: 2026-02-15T15:39:11.578Z
Learning: Applies to netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/**/*.html : HTMX fragments should live in `templates/netbox_librenms_plugin/htmx/`. Keep server responses and HTMX targets in sync.

Applied to files:

  • netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html
  • netbox_librenms_plugin/templates/netbox_librenms_plugin/inc/_module_sync.html
📚 Learning: 2026-02-15T15:39:11.578Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/frontend.instructions.md:0-0
Timestamp: 2026-02-15T15:39:11.578Z
Learning: Applies to netbox_librenms_plugin/templates/**/*.html : Modal buttons should target the `htmx-modal-content` element. 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/inc/_module_sync.html
📚 Learning: 2026-02-15T15:39:11.578Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/frontend.instructions.md:0-0
Timestamp: 2026-02-15T15:39:11.578Z
Learning: Applies to netbox_librenms_plugin/templates/netbox_librenms_plugin/**/*.html : Templates should live in `templates/netbox_librenms_plugin/`. Reusable template includes should be placed under `inc/` subdirectory.

Applied to files:

  • netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html
  • netbox_librenms_plugin/templates/netbox_librenms_plugin/inc/_module_sync.html
📚 Learning: 2026-02-15T15:39:11.578Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/frontend.instructions.md:0-0
Timestamp: 2026-02-15T15:39:11.578Z
Learning: Applies to netbox_librenms_plugin/templates/**/*.html : Modals use Tabler (Bootstrap-like) but without `bootstrap.Modal` helpers.

Applied to files:

  • netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html
📚 Learning: 2026-02-15T15:38:54.189Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-02-15T15:38:54.189Z
Learning: Applies to views/imports/**/*.py : REST endpoints for imports live in `views/imports/actions.py` with the list view in `views/imports/list.py`, surface via `urls.py`, and emit HTMX fragments in `templates/netbox_librenms_plugin/htmx/`; keep server responses and HTMX targets in sync

Applied to files:

  • netbox_librenms_plugin/views/base/modules_view.py
  • netbox_librenms_plugin/templates/netbox_librenms_plugin/inc/_module_sync.html
  • netbox_librenms_plugin/views/imports/actions.py
  • netbox_librenms_plugin/forms.py
📚 Learning: 2026-02-15T15:38:54.189Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-02-15T15:38:54.189Z
Learning: Applies to {navigation.py,urls.py,api/**} : Respect NetBox plugin APIs in `navigation.py`, `urls.py`, and `api/` directories

Applied to files:

  • netbox_librenms_plugin/views/base/modules_view.py
  • .devcontainer/scripts/setup.sh
  • netbox_librenms_plugin/forms.py
📚 Learning: 2026-02-15T15:38:54.189Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-02-15T15:38:54.189Z
Learning: Applies to views/**/*.py : Views should follow layered structure: extend the closest base class from `views/base/` and compose mixins like `LibreNMSAPIMixin` and `CacheMixin`

Applied to files:

  • netbox_librenms_plugin/views/base/modules_view.py
📚 Learning: 2026-02-15T15:39:20.744Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/testing.instructions.md:0-0
Timestamp: 2026-02-15T15:39:20.744Z
Learning: Applies to tests/test_background_jobs.py : Patch `cache` where imported: `netbox_librenms_plugin.views.imports.list.cache`, not `django.core.cache.cache`

Applied to files:

  • netbox_librenms_plugin/views/base/modules_view.py
📚 Learning: 2026-02-15T15:38:54.189Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-02-15T15:38:54.189Z
Learning: Applies to **/*.py : Always call `LibreNMSAPI.get_librenms_id` to retrieve the device/VM LibreNMS mapping via the `librenms_id` custom field instead of touching the field directly

Applied to files:

  • netbox_librenms_plugin/__init__.py
  • netbox_librenms_plugin/views/imports/actions.py
📚 Learning: 2026-02-15T15:39:20.744Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/testing.instructions.md:0-0
Timestamp: 2026-02-15T15:39:20.744Z
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/__init__.py
📚 Learning: 2026-02-15T15:39:03.405Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/background-jobs.instructions.md:0-0
Timestamp: 2026-02-15T15:39:03.405Z
Learning: Applies to **/views/imports/** : `api/views.py::sync_job_status()` must sync database Job status with RQ job status because NetBox worker doesn't always update DB when jobs stop before processing starts

Applied to files:

  • netbox_librenms_plugin/views/imports/actions.py
📚 Learning: 2026-02-15T15:38:54.189Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-02-15T15:38:54.189Z
Learning: Applies to **/*.py : Matching must be exact-only for site, platform, device type, and role; do not add fuzzy matching; use the functions `find_matching_site`, `match_librenms_hardware_to_device_type`, and `find_matching_platform` from `utils.py`

Applied to files:

  • netbox_librenms_plugin/views/imports/actions.py
📚 Learning: 2026-02-15T15:38:54.189Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-02-15T15:38:54.189Z
Learning: Applies to **/*.py : Reuse the `librenms_api.py` LibreNMS client instead of making new `requests` calls; it handles multi-server configs via `LibreNMSSettings` model and the `servers` plugin config, plus caching

Applied to files:

  • netbox_librenms_plugin/forms.py
🧬 Code graph analysis (5)
netbox_librenms_plugin/views/base/cables_view.py (1)
netbox_librenms_plugin/utils.py (1)
  • get_interface_name_field (164-192)
netbox_librenms_plugin/views/sync/interfaces.py (2)
netbox_librenms_plugin/views/base/librenms_sync_view.py (1)
  • get (25-46)
netbox_librenms_plugin/views/settings_views.py (1)
  • get (26-43)
.devcontainer/scripts/load-aliases.sh (1)
.devcontainer/scripts/process-helpers.sh (3)
  • is_expected_pid (21-24)
  • graceful_kill_pid (6-11)
  • graceful_kill_pattern (13-18)
netbox_librenms_plugin/views/imports/actions.py (2)
netbox_librenms_plugin/utils.py (2)
  • find_matching_platform (297-327)
  • match_librenms_hardware_to_device_type (195-260)
netbox_librenms_plugin/import_utils.py (2)
  • _determine_device_name (230-283)
  • get_import_device_cache_key (208-227)
netbox_librenms_plugin/forms.py (3)
netbox_librenms_plugin/models.py (11)
  • InterfaceTypeMapping (55-81)
  • LibreNMSSettings (11-52)
  • ModuleBayMapping (148-202)
  • ModuleTypeMapping (116-145)
  • NormalizationRule (205-279)
  • Meta (41-45)
  • Meta (74-78)
  • Meta (107-110)
  • Meta (139-142)
  • Meta (194-198)
  • Meta (273-276)
netbox_librenms_plugin/views/mixins.py (1)
  • librenms_api (217-231)
netbox_librenms_plugin/librenms_api.py (2)
  • LibreNMSAPI (15-1080)
  • get_poller_groups (734-765)
🪛 Shellcheck (0.11.0)
.devcontainer/scripts/start-netbox.sh

[info] 25-25: Not following: ./process-helpers.sh was not specified as input (see shellcheck -x).

(SC1091)

.devcontainer/scripts/welcome.sh

[info] 5-5: Not following: ./load-aliases.sh was not specified as input (see shellcheck -x).

(SC1091)

.devcontainer/scripts/load-aliases.sh

[info] 26-26: Not following: ./.devcontainer/scripts/process-helpers.sh was not specified as input (see shellcheck -x).

(SC1091)


[info] 132-132: Not following: /opt/netbox/venv/bin/activate was not specified as input (see shellcheck -x).

(SC1091)


[info] 136-136: Not following: /opt/netbox/venv/bin/activate was not specified as input (see shellcheck -x).

(SC1091)


[info] 140-140: Not following: /opt/netbox/venv/bin/activate was not specified as input (see shellcheck -x).

(SC1091)


[info] 154-154: Not following: /opt/netbox/venv/bin/activate was not specified as input (see shellcheck -x).

(SC1091)


[info] 168-168: Not following: /opt/netbox/venv/bin/activate was not specified as input (see shellcheck -x).

(SC1091)


[info] 172-172: Not following: /opt/netbox/venv/bin/activate was not specified as input (see shellcheck -x).

(SC1091)


[info] 177-177: Not following: /opt/netbox/venv/bin/activate was not specified as input (see shellcheck -x).

(SC1091)


[info] 182-182: Not following: /opt/netbox/venv/bin/activate was not specified as input (see shellcheck -x).

(SC1091)

🔇 Additional comments (17)
netbox_librenms_plugin/templates/netbox_librenms_plugin/inc/_module_sync.html (2)

12-16: HTMX implementation follows guidelines.

The button correctly uses hx-post with a targeted innerHTML swap to #module-sync-content, avoiding outerHTML swaps per project conventions. The CSRF token is properly included via the parent form.


26-26: Update include path to use inc/ subdirectory.

The included partial _module_sync_content.html should follow the same convention and be referenced from the inc/ subdirectory if it's a reusable include.

-    {% include 'netbox_librenms_plugin/_module_sync_content.html' %}
+    {% include 'netbox_librenms_plugin/inc/_module_sync_content.html' %}

As per coding guidelines, "Reusable template includes should be placed under inc/ subdirectory."

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

245-271: LGTM! Name sync row properly guards against placeholder sysName values.

The conditional logic correctly excludes the placeholder "-" value from triggering the sync button, addressing the previous review feedback.

Minor nit: Line 270 uses an em-dash () for the default while the mismatch modal (lines 666-667) uses a regular hyphen (-). Consider using consistent placeholder characters throughout the template for visual consistency.


292-296: Appropriate visual distinction for device type sync.

Using btn-outline-danger for the device type sync button appropriately signals that this is a more impactful operation compared to other field syncs.


538-545: LGTM!

The Modules tab navigation follows the established pattern used by other conditional tabs (e.g., VLANs, Cables) and is properly gated behind the module_sync flag.


575-580: LGTM!

The Modules tab content pane is properly structured and the include path correctly places the reusable partial under inc/ as per coding guidelines.

netbox_librenms_plugin/views/base/cables_view.py (1)

55-66: LGTM! Safe handling of port data now in place.

The refactored logic correctly addresses the prior concerns:

  • Dynamic resolution of interface_name_field per-request via the utility function
  • Proper None guard on raw_port_id before string conversion prevents the bogus "None" key issue
  • Using .get() for interface_name_field lookup prevents KeyError when the field is missing from port data
netbox_librenms_plugin/views/imports/actions.py (1)

752-767: Good fix: unknown hardware mappings no longer produce false “all synced.”

Line 763 now correctly marks unmatched hardware as unsynced, which prevents false positives in all_synced at Line 766.

netbox_librenms_plugin/views/sync/cables.py (3)

1-14: Logger initialization is clean and appropriate.

Using a module-level logger = logging.getLogger(__name__) is the right pattern for scoped exception logging.


49-53: Docstring return contract is clearer now.

The updated create_cable docstring correctly describes the boolean success/failure contract.


92-92: Good improvement: preserve interface context on invalid match.

Including interface in the StopIteration result improves downstream UX/reporting consistency.

.devcontainer/scripts/welcome.sh (1)

10-11: Dynamic workspace detection and messaging update look solid.

PLUGIN_WS_DIR fallback logic and the corrected browser hint improve portability and clarity.

Also applies to: 48-48

.devcontainer/scripts/diagnose.sh (1)

42-44: Empty PID-file handling is a good robustness improvement.

The explicit Line 42-44 branch avoids misleading "dead PID" messaging when the PID file is empty.

.devcontainer/scripts/start-netbox.sh (1)

24-63: Process-helper integration is a strong improvement.

Using is_expected_pid and shared graceful-kill helpers here makes shutdown behavior consistent with alias commands and reduces accidental stale-PID kills.

.devcontainer/scripts/setup.sh (1)

262-273: Superuser credential handling is safer now.

Reading credentials from environment and logging only the username (Line 272) is the right security/UX direction for devcontainer setup.

netbox_librenms_plugin/forms.py (2)

76-83: Good fix: cache keys are now server-scoped.

Using api.librenms_url in cache keys prevents cross-server choice leakage when switching active LibreNMS servers.

Also applies to: 780-783


397-397: The concern about forms.NullBooleanField breaking on newer Django versions is unfounded. forms.NullBooleanField is not deprecated or removed in Django 5.1.x (which NetBox 4.2.x requires), and is still officially documented and supported. The current code at line 397 will not break and does not require changes.

Likely an incorrect or invalid review comment.

Comment thread .devcontainer/scripts/diagnose.sh Outdated
Comment on lines +93 to +129
netbox-status() {
local PID
if [ -f /tmp/netbox.pid ]; then
PID=$(cat /tmp/netbox.pid 2>/dev/null)
if [ -n "$PID" ] && is_expected_pid "$PID" "python.*runserver.*8000"; then
echo "NetBox is running (PID: $PID)"
else
echo "NetBox is not running"
fi
else
echo "NetBox is not running"
fi
if [ -f /tmp/rqworker.pid ]; then
PID=$(cat /tmp/rqworker.pid 2>/dev/null)
if [ -n "$PID" ] && is_expected_pid "$PID" "python.*rqworker"; then
echo "RQ worker is running (PID: $PID)"
else
echo "RQ worker is not running"
fi
else
echo "RQ worker is not running"
fi
}

rq-status() {
local PID
if [ -f /tmp/rqworker.pid ]; then
PID=$(cat /tmp/rqworker.pid 2>/dev/null)
if [ -n "$PID" ] && is_expected_pid "$PID" "python.*rqworker"; then
echo "RQ worker is running (PID: $PID)"
else
echo "RQ worker is not running"
fi
else
echo "RQ worker is not running"
fi
}

ghost Feb 26, 2026

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 | 🟡 Minor

Status commands miss running orphan processes.

Lines 95-127 only trust PID files, so they can report false "not running" when NetBox/RQ is alive but untracked.

Suggested fix
 netbox-status() {
   local PID
   if [ -f /tmp/netbox.pid ]; then
     PID=$(cat /tmp/netbox.pid 2>/dev/null)
     if [ -n "$PID" ] && is_expected_pid "$PID" "python.*runserver.*8000"; then
       echo "NetBox is running (PID: $PID)"
+    elif pgrep -f "python.*runserver.*8000" >/dev/null 2>&1; then
+      echo "NetBox is running (PID file missing/stale)"
     else
       echo "NetBox is not running"
     fi
   else
-    echo "NetBox is not running"
+    if pgrep -f "python.*runserver.*8000" >/dev/null 2>&1; then
+      echo "NetBox is running (PID file missing/stale)"
+    else
+      echo "NetBox is not running"
+    fi
   fi
@@
   if [ -f /tmp/rqworker.pid ]; then
     PID=$(cat /tmp/rqworker.pid 2>/dev/null)
     if [ -n "$PID" ] && is_expected_pid "$PID" "python.*rqworker"; then
       echo "RQ worker is running (PID: $PID)"
+    elif pgrep -f "python.*rqworker" >/dev/null 2>&1; then
+      echo "RQ worker is running (PID file missing/stale)"
     else
       echo "RQ worker is not running"
     fi
   else
-    echo "RQ worker is not running"
+    if pgrep -f "python.*rqworker" >/dev/null 2>&1; then
+      echo "RQ worker is running (PID file missing/stale)"
+    else
+      echo "RQ worker is not running"
+    fi
   fi
 }
@@
 rq-status() {
   local PID
   if [ -f /tmp/rqworker.pid ]; then
     PID=$(cat /tmp/rqworker.pid 2>/dev/null)
     if [ -n "$PID" ] && is_expected_pid "$PID" "python.*rqworker"; then
       echo "RQ worker is running (PID: $PID)"
+    elif pgrep -f "python.*rqworker" >/dev/null 2>&1; then
+      echo "RQ worker is running (PID file missing/stale)"
     else
       echo "RQ worker is not running"
     fi
   else
-    echo "RQ worker is not running"
+    if pgrep -f "python.*rqworker" >/dev/null 2>&1; then
+      echo "RQ worker is running (PID file missing/stale)"
+    else
+      echo "RQ worker is not running"
+    fi
   fi
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.devcontainer/scripts/load-aliases.sh around lines 93 - 129, The
netbox-status and rq-status functions currently only check PID files and thus
can falsely report services as not running; update both to fall back to
searching the process table when the PID file is missing or the PID doesn't
match is_expected_pid. Specifically, in netbox-status (function name
netbox-status) and rq-status (function name rq-status) keep the PID-file checks
but add a fallback that uses pgrep -f or ps -ef | grep -E to look for processes
matching the same patterns used by is_expected_pid ("python.*runserver.*8000"
for NetBox and "python.*rqworker" for RQ), capture and report any found PID(s)
and their state, and only report "not running" when neither the PID file nor the
process-table search finds a matching process. Ensure the new logic preserves
existing calls to is_expected_pid and handles multiple PIDs gracefully (report
list or first PID).

Comment thread .devcontainer/scripts/process-helpers.sh
Comment thread netbox_librenms_plugin/__init__.py
Comment on lines +93 to +96
cached_data = cache.get(self.get_cache_key(obj, "inventory"))
if not cached_data:
return {"table": None, "object": obj, "cache_expiry": None}
return self._build_context(request, obj, cached_data)

ghost Feb 26, 2026

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 | 🟡 Minor

Treat cached empty inventory as a valid cache hit.

An empty cached list currently renders as “no cache”, so valid empty results are lost on initial load.

🔧 Proposed fix
-        if not cached_data:
+        if cached_data is None:
             return {"table": None, "object": obj, "cache_expiry": None}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@netbox_librenms_plugin/views/base/modules_view.py` around lines 93 - 96, The
cache check in modules_view treats falsy values (like empty lists) as cache
misses; change the conditional that inspects cached_data (from
cache.get(self.get_cache_key(obj, "inventory"))) to explicitly check for None
(e.g., if cached_data is None) so an empty inventory list is treated as a valid
cache hit and still passed into _build_context(request, obj, cached_data); keep
the early return only for None results.

Comment on lines +994 to +1015
device_bays = ModuleBay.objects.filter(device=device, module_id__isnull=True).select_related(
"installed_module"
)

for bay in device_bays:
if hasattr(bay, "installed_module") and bay.installed_module:
if bay.name == parent_name or (parent_descr and bay.name == parent_descr):
return bay.installed_module.pk

# Also check ModuleBayMapping for indirect matches
for name in [parent_name, parent_descr]:
if not name:
continue
mapping = ModuleBayMapping.objects.filter(librenms_name=name).first()
if mapping:
bay = (
ModuleBay.objects.filter(device=device, name=mapping.netbox_bay_name, module_id__isnull=True)
.select_related("installed_module")
.first()
)
if bay and hasattr(bay, "installed_module") and bay.installed_module:
return bay.installed_module.pk

ghost Feb 26, 2026

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

🧩 Analysis chain

🏁 Script executed:

# Find the file in question and understand its context
fd "modules_view.py" --type f

Repository: marcinpsk/netbox-librenms-plugin

Length of output: 125


🏁 Script executed:

# Check the ModuleBay model definition in NetBox
find . -name "*.py" -type f | xargs grep -l "class ModuleBay" | head -5

Repository: marcinpsk/netbox-librenms-plugin

Length of output: 262


🏁 Script executed:

# Search for module_id field definition in ModuleBay
rg "module_id" --type py -B 2 -A 2 | grep -A 5 -B 5 "ModuleBay"

Repository: marcinpsk/netbox-librenms-plugin

Length of output: 4113


🏁 Script executed:

# Look at the actual code context around lines 994-1015
cat -n "netbox_librenms_plugin/views/base/modules_view.py" | sed -n '980,1030p'

Repository: marcinpsk/netbox-librenms-plugin

Length of output: 2609


🏁 Script executed:

# Check the ModuleBayMapping model definition
cat -n netbox_librenms_plugin/models.py | grep -A 20 "class ModuleBayMapping"

Repository: marcinpsk/netbox-librenms-plugin

Length of output: 1103


🏁 Script executed:

# Search for is_regex field definition
rg "is_regex" --type py -B 2 -A 2

Repository: marcinpsk/netbox-librenms-plugin

Length of output: 6968


Parent-module lookup excludes module-scoped bays, breaking deeper branch installs.

Filtering only module_id__isnull=True in _find_parent_module_id prevents resolution of parents that are themselves nested within installed modules. When walking up the LibreNMS hierarchy, module-level parents will not be found if they belong to another module context.

Additionally, the ModuleBayMapping query should filter is_regex=False for exact-match lookups to align with the pattern used elsewhere in the codebase.

🔧 Proposed fix
-            device_bays = ModuleBay.objects.filter(device=device, module_id__isnull=True).select_related(
-                "installed_module"
-            )
+            device_bays = ModuleBay.objects.filter(device=device).select_related("installed_module")
@@
-                mapping = ModuleBayMapping.objects.filter(librenms_name=name).first()
+                mapping = ModuleBayMapping.objects.filter(librenms_name=name, is_regex=False).first()
@@
-                        ModuleBay.objects.filter(device=device, name=mapping.netbox_bay_name, module_id__isnull=True)
+                        ModuleBay.objects.filter(device=device, name=mapping.netbox_bay_name)
                         .select_related("installed_module")
                         .first()
📝 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
device_bays = ModuleBay.objects.filter(device=device, module_id__isnull=True).select_related(
"installed_module"
)
for bay in device_bays:
if hasattr(bay, "installed_module") and bay.installed_module:
if bay.name == parent_name or (parent_descr and bay.name == parent_descr):
return bay.installed_module.pk
# Also check ModuleBayMapping for indirect matches
for name in [parent_name, parent_descr]:
if not name:
continue
mapping = ModuleBayMapping.objects.filter(librenms_name=name).first()
if mapping:
bay = (
ModuleBay.objects.filter(device=device, name=mapping.netbox_bay_name, module_id__isnull=True)
.select_related("installed_module")
.first()
)
if bay and hasattr(bay, "installed_module") and bay.installed_module:
return bay.installed_module.pk
device_bays = ModuleBay.objects.filter(device=device).select_related(
"installed_module"
)
for bay in device_bays:
if hasattr(bay, "installed_module") and bay.installed_module:
if bay.name == parent_name or (parent_descr and bay.name == parent_descr):
return bay.installed_module.pk
# Also check ModuleBayMapping for indirect matches
for name in [parent_name, parent_descr]:
if not name:
continue
mapping = ModuleBayMapping.objects.filter(librenms_name=name, is_regex=False).first()
if mapping:
bay = (
ModuleBay.objects.filter(device=device, name=mapping.netbox_bay_name)
.select_related("installed_module")
.first()
)
if bay and hasattr(bay, "installed_module") and bay.installed_module:
return bay.installed_module.pk
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@netbox_librenms_plugin/views/base/modules_view.py` around lines 994 - 1015,
The parent-module lookup in _find_parent_module_id excludes module-scoped bays
by filtering ModuleBay with module_id__isnull=True; remove that restriction (or
also query without module_id__isnull) so bays installed inside other modules are
considered when checking bay.name == parent_name/parent_descr and when resolving
mapping.netbox_bay_name; also tighten the ModuleBayMapping lookup to only exact
matches by adding is_regex=False to the filter (i.e.,
ModuleBayMapping.objects.filter(librenms_name=name, is_regex=False).first()) so
mapping resolution matches the rest of the codebase.

Comment thread netbox_librenms_plugin/views/imports/actions.py Outdated
Comment on lines 128 to +137
results = {"valid": [], "invalid": [], "duplicate": [], "missing_remote": []}

with transaction.atomic():
for interface in selected_interfaces:
result = self.process_single_interface(interface, cached_links)
for interface in selected_interfaces:
try:
with transaction.atomic():
result = self.process_single_interface(interface, cached_links)
results[result["status"]].append(result.get("interface", ""))
except Exception:
logger.exception("Failed to sync cable for interface %s", interface.get("interface", ""))
results["invalid"].append(interface.get("interface", ""))

ghost Feb 26, 2026

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 | 🟡 Minor

Separate unexpected processing errors from “invalid link data.”

Line 137 classifies unexpected exceptions as invalid, but invalid is later shown as “No LibreNMS link data found...”. This can mislead users when the real cause is an internal/runtime failure.

Proposed diff
@@
-        results = {"valid": [], "invalid": [], "duplicate": [], "missing_remote": []}
+        results = {"valid": [], "invalid": [], "duplicate": [], "missing_remote": [], "failed": []}
@@
         for interface in selected_interfaces:
             try:
                 with transaction.atomic():
                     result = self.process_single_interface(interface, cached_links)
                 results[result["status"]].append(result.get("interface", ""))
             except Exception:
                 logger.exception("Failed to sync cable for interface %s", interface.get("interface", ""))
-                results["invalid"].append(interface.get("interface", ""))
+                results["failed"].append(interface.get("interface", ""))
@@
     def display_sync_results(self, request, results):
         """Display flash messages summarizing the cable sync results."""
+        if results["failed"]:
+            messages.error(
+                request,
+                f"Unexpected sync failure for interfaces: {', '.join(results['failed'])}. Check logs for details.",
+            )
         if results["missing_remote"]:
             messages.error(
                 request,
                 f"Remote device or interface not found in NetBox for: {', '.join(results['missing_remote'])}",
             )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@netbox_librenms_plugin/views/sync/cables.py` around lines 128 - 137, The loop
that syncs interfaces currently treats all unexpected exceptions as "invalid"
which conflates real validation errors with runtime failures; modify the results
dict to include a distinct category (e.g., "error" or "failed") and change the
exception handler in the selected_interfaces loop to append
interface.get("interface","") to results["error"] instead of results["invalid"],
while keeping the logger.exception call for the stack trace; ensure any
downstream code that reads results (or user-facing messages) is updated to
handle the new "error" key so users see a clear distinction between invalid link
data and internal runtime errors.

Comment on lines +242 to +244
if "mac_address" not in exclude_columns:
ifPhysAddress = librenms_interface.get("ifPhysAddress")
self.handle_mac_address(interface, ifPhysAddress)

ghost Feb 26, 2026

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

🧩 Analysis chain

🏁 Script executed:

# First, let's examine the interfaces.py file to see the handle_mac_address method and context
wc -l netbox_librenms_plugin/views/sync/interfaces.py

Repository: marcinpsk/netbox-librenms-plugin

Length of output: 127


🏁 Script executed:

# Read the relevant sections of the file
sed -n '180,200p' netbox_librenms_plugin/views/sync/interfaces.py

Repository: marcinpsk/netbox-librenms-plugin

Length of output: 799


🏁 Script executed:

# Read lines around 242 to see context and is_device_interface
sed -n '200,250p' netbox_librenms_plugin/views/sync/interfaces.py

Repository: marcinpsk/netbox-librenms-plugin

Length of output: 2232


🏁 Script executed:

# Check handle_mac_address method implementation
sed -n '183,193p' netbox_librenms_plugin/views/sync/interfaces.py

Repository: marcinpsk/netbox-librenms-plugin

Length of output: 593


🏁 Script executed:

# Search for is_device_interface variable definition
rg -n "is_device_interface" netbox_librenms_plugin/views/sync/interfaces.py

Repository: marcinpsk/netbox-librenms-plugin

Length of output: 221


🏁 Script executed:

# Search for VMInterface imports and usage in the file
rg -n "VMInterface" netbox_librenms_plugin/views/sync/interfaces.py

Repository: marcinpsk/netbox-librenms-plugin

Length of output: 599


🏁 Script executed:

# Let's verify if VMInterface has mac_addresses attribute
# First, find where VMInterface is defined
find . -name "*.py" -type f | xargs grep -l "class VMInterface" | head -5

Repository: marcinpsk/netbox-librenms-plugin

Length of output: 125


🏁 Script executed:

# Search in the codebase for any VMInterface.mac_addresses references
rg "VMInterface.*mac_address" -A 2 -B 2

Repository: marcinpsk/netbox-librenms-plugin

Length of output: 58


🏁 Script executed:

# Check NetBox virtualization models documentation or source
# Let's look for interface-related models and their mac_address handling
rg "class.*Interface.*:" --type py | head -20

Repository: marcinpsk/netbox-librenms-plugin

Length of output: 2495


🏁 Script executed:

# More specifically, check what attributes are used on interfaces
rg "interface\.mac_address" --type py -B 2 -A 2

Repository: marcinpsk/netbox-librenms-plugin

Length of output: 1386


🏁 Script executed:

# Check if there are any conditions that guard mac_address operations
rg "mac_address.*not in exclude" --type py -B 5 -A 5

Repository: marcinpsk/netbox-librenms-plugin

Length of output: 994


🏁 Script executed:

# Check the update_interface_attributes method flow to see how it's called
rg -n "update_interface_attributes" netbox_librenms_plugin/views/sync/interfaces.py -B 5 -A 10

Repository: marcinpsk/netbox-librenms-plugin

Length of output: 1224


🏁 Script executed:

# Search for any try-except blocks around handle_mac_address or mac_addresses access
rg -n "try:" netbox_librenms_plugin/views/sync/interfaces.py -A 10 | grep -A 10 "mac_address"

Repository: marcinpsk/netbox-librenms-plugin

Length of output: 58


🏁 Script executed:

# Check if there are any imports or references that indicate NetBox version or VMInterface capabilities
rg -n "from.*models import" netbox_librenms_plugin/views/sync/interfaces.py

Repository: marcinpsk/netbox-librenms-plugin

Length of output: 261


🏁 Script executed:

# Look at the actual call sites for update_interface_attributes to confirm both Device and VMInterface are passed
rg -n "\.update_interface_attributes" netbox_librenms_plugin/views/sync/interfaces.py -B 10 -A 2

Repository: marcinpsk/netbox-librenms-plugin

Length of output: 684


🏁 Script executed:

# Check if there are any references to VMInterface.mac_addresses anywhere in the codebase
rg "VMInterface" netbox_librenms_plugin/ -A 5 | grep -i "mac"

Repository: marcinpsk/netbox-librenms-plugin

Length of output: 1938


🏁 Script executed:

# Let's look at the full flow where update_interface_attributes is called to confirm both paths
sed -n '140,170p' netbox_librenms_plugin/views/sync/interfaces.py

Repository: marcinpsk/netbox-librenms-plugin

Length of output: 1268


🏁 Script executed:

# Check if there's any error handling or test cases for VMInterface with mac_address
rg -n "VMInterface" netbox_librenms_plugin/tests/ -A 3 -B 3

Repository: marcinpsk/netbox-librenms-plugin

Length of output: 2822


🏁 Script executed:

# Let's search for any documentation or comments about VMInterface capabilities
rg -n "VMInterface.*:" netbox_librenms_plugin/ -A 5 | head -30

Repository: marcinpsk/netbox-librenms-plugin

Length of output: 1797


🏁 Script executed:

# Check if there's any hasattr check or try-except around VMInterface in the codebase
rg "hasattr.*interface.*mac" netbox_librenms_plugin/ -B 2 -A 2

Repository: marcinpsk/netbox-librenms-plugin

Length of output: 651


🏁 Script executed:

# Let's verify the actual NetBox model imports and see if there are any safeguards
rg "from dcim" netbox_librenms_plugin/views/sync/interfaces.py -A 2

Repository: marcinpsk/netbox-librenms-plugin

Length of output: 201


🏁 Script executed:

# Check if there's a test that attempts to sync a VMInterface with mac_address
rg -n "handle_mac_address\|mac_address" netbox_librenms_plugin/tests/ -B 3 -A 3

Repository: marcinpsk/netbox-librenms-plugin

Length of output: 58


🏁 Script executed:

# Let's verify once more by checking if there's any error handling around the full sync flow
sed -n '130,180p' netbox_librenms_plugin/views/sync/interfaces.py

Repository: marcinpsk/netbox-librenms-plugin

Length of output: 2451


🏁 Script executed:

# Check if mac_address handling is tested anywhere or if exclude_columns typically includes mac_address
rg -n "exclude_columns" netbox_librenms_plugin/views/sync/interfaces.py -B 2 -A 2

Repository: marcinpsk/netbox-librenms-plugin

Length of output: 2273


Guard MAC address sync with is_device_interface to prevent AttributeError on VMInterface objects.

handle_mac_address() accesses interface.mac_addresses and interface.primary_mac_address, which exist on dcim.Interface but not on virtualization.VMInterface. When syncing a VM interface with MAC address not excluded, this will raise an AttributeError. Apply the same is_device_interface guard used for ifType handling at line 222.

Proposed fix
-        if "mac_address" not in exclude_columns:
+        if "mac_address" not in exclude_columns and is_device_interface:
             ifPhysAddress = librenms_interface.get("ifPhysAddress")
             self.handle_mac_address(interface, ifPhysAddress)
📝 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
if "mac_address" not in exclude_columns:
ifPhysAddress = librenms_interface.get("ifPhysAddress")
self.handle_mac_address(interface, ifPhysAddress)
if "mac_address" not in exclude_columns and is_device_interface:
ifPhysAddress = librenms_interface.get("ifPhysAddress")
self.handle_mac_address(interface, ifPhysAddress)
🤖 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 242 - 244, The
MAC-address sync must be guarded by the same device-interface check as the
ifType code to avoid AttributeError on virtualization.VMInterface; modify the
block that checks "mac_address" and calls handle_mac_address(interface,
ifPhysAddress) to only run when is_device_interface(interface) is true (i.e.,
wrap the existing exclude_columns check and handle_mac_address call with an
is_device_interface(interface) guard or combine both conditions), using the
existing is_device_interface helper and keeping the ifPhysAddress variable and
handle_mac_address invocation as-is.

Marcin Zieba and others added 12 commits February 26, 2026 22:44
Break the monolithic import_utils.py into focused modules:

- permissions.py: user permission checks
- cache.py: cache key generation and management
- filters.py: device filtering and retrieval from LibreNMS
- virtual_chassis.py: VC detection, creation, member management
- device_operations.py: device validation, import, and fetch
- vm_operations.py: VM creation and bulk import
- bulk_import.py: bulk device import orchestration and filter processing

The __init__.py re-exports all public names, so existing callers
(views, jobs, tests) continue working without import changes.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
After splitting import_utils.py into a package, mock.patch decorators
must target the actual submodule where each name is looked up, not the
package __init__.py. Update all patch paths in test_import_utils.py and
test_permissions.py to point to the correct submodules.
Keep Cluster as a top-level import so mock.patch targets can resolve it.
Remove the redundant inline import inside validate_device_for_import.
Now that Cluster is a module-level import in device_operations,
the test only needs to mock device_operations.Cluster — the extra
patch on virtualization.models.Cluster was leftover from the
inline-import era and caused the mock to target the wrong object.
bonzo81#227)

- Add use_sysname/strip_domain params to validate_device_for_import
- Store resolved name in validation result as resolved_name
- Update import_single_device, bulk_import_devices_shared, bulk_import_vms
  to extract and pass naming prefs from sync_options
- Add _resolve_naming_preferences() helper in actions.py with fallback chain:
  POST data -> user pref -> LibreNMSSettings -> plugin default
- Update DeviceImportHelperMixin and BulkImportConfirmView to pass prefs
- Replace _determine_device_name() in BulkImportConfirmView with resolved_name
- Add TestDeviceNamingPreferences (5 tests) to test_import_utils.py
…ort_utils package

Port 4 commits from feat/serial-matching-and-conflict-resolution:
- 097ea42: Fix validation readiness (VMs require cluster.found), hostname
  match always sets has_actions, guard platform sync forms for Device only,
  use _resolve_naming_preferences in BulkImportConfirmView, add else branch
  for unmatched hardware in _build_sync_info, reuse resolved_name in
  DeviceConflictActionView actions, fix test patch targets
- c0d7bfc: Block import when issues present (can_import/is_ready require
  no issues), modal title uses resolved_name, dedup LibreNMSSettings DB
  query in _resolve_naming_preferences, guard hostname form for VMs
- 13bafe1: Compare existing device/VM name against resolved_name instead
  of raw sysName, scope mismatch force gate to link/update/update_serial/
  update_type only, add librenms_id collision check before linking
- dad0d22: Update device_role dict in-place during refresh to preserve
  schema keys (available_roles etc.)
The librenms_id conflict check added in the port requires
Device.objects.filter().exclude().first() to be mocked for all
tests using link/update/update_serial actions.
- utils.py set_librenms_device_id: defensively handle unexpected types
  (non-int, non-dict) by resetting to empty dict with a warning log
- utils.py find_by_librenms_id: add OR query to also match legacy records
  where librenms_id is stored as a bare integer
- cables_view.py: update all 6 librenms_id lookup sites to use Q()|Q()
  so legacy integer records are matched alongside new JSON format
- actions.py: validate librenms_id (device_id) before int() cast and
  return 400 if missing/non-numeric; use pre-validated int throughout
  the link/update/update_serial action blocks
- interfaces.py: guard against None port_id before set_librenms_device_id
  to avoid clobbering an existing server-key mapping
find_by_librenms_id now calls .filter(Q(...)|Q(...)) passing a positional
Q object. The three TestSerialNumberMatching mocks only accepted **kwargs,
causing TypeError which was silently caught by the except block in
device_operations.py, making existing_device None.

Update all three device_filter side_effects to accept *args and check
str(arg) for 'librenms_id' so Q-based lookups are detected correctly.
- Add module sync (ENTITY-MIB inventory via SNMP) from inventory branch
- Resolve 5 merge conflicts: devcontainer scripts, forms.py, views/__init__.py, actions.py
- Accept deletion of monolithic import_utils.py (replaced by split package)
- Port _try_chassis_device_type_match() + chassis fallback to device_operations.py
- Port _refresh_existing_device() rewrite to bulk_import.py (uses find_by_librenms_id for JSON compat)
- Add DeviceTypeMapping/ModuleTypeMapping/NormalizationRule views to __init__.py
- Server-specific cache key in poller group choices (forms.py)
Marcin Zieba added 2 commits March 1, 2026 17:28
- __init__.py: create librenms_id custom field as type 'json' (was
  'integer'); auto-migrate existing integer-typed fields to 'json' on
  post_migrate so the multi-server dict format is accepted by the UI

- modules_view.py: pass device_bays (not all_bays) to _build_row for
  ENTITY-MIB top-level items to prevent module-scoped bay name collisions
  with device-level bays of the same name

- modules_view.py: suppress Install Branch when all candidate children
  have module types that require {module_path} on an unsupported NetBox
  version; now checks module_type_uses_module_path + supports_module_path
  before setting has_installable_children
- Never block module installation based on {module_path} compatibility.
  The old 'Requires Upgrade' status and can_install=False guard are
  replaced with informational badges:
  * module_path_warning → 'Upgrade NetBox' warning icon with tooltip
    (module uses {module_path} but current NetBox does not support it;
    installation proceeds, interface naming may not be perfect)
  * module_type_upgrade_hint → info icon on end modules still using
    {module} when the running NetBox already supports {module_path}
    (suggests updating the module type's interface templates)

- Add module_type_uses_module_token() and module_type_is_end_module()
  helpers to utils.py.

- Remove module_path_blocked guards from InstallModuleView.post() and
  InstallBranchView._install_single() — install always proceeds.

- Revert Install Branch heuristic: any matched child module type enables
  the button regardless of {module_path} support.

- _determine_status() no longer returns 'Requires Upgrade'.

ghost 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: 16

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/urls.py (1)

92-557: 🧹 Nitpick | 🔵 Trivial

Consider splitting urlpatterns into domain-specific lists.

urlpatterns is getting large and merge-conflict-prone. Grouping into smaller lists (sync/import/mappings/settings) and concatenating would reduce maintenance overhead.

♻️ Example structure
+device_sync_urlpatterns = [
+    # device sync routes...
+]
+
+mapping_urlpatterns = [
+    # mapping CRUD routes...
+]
+
+settings_urlpatterns = [
+    # settings/test routes...
+]
+
 urlpatterns = [
-    # all routes inline...
-    path("api/", include("netbox_librenms_plugin.api.urls")),
+    *device_sync_urlpatterns,
+    *mapping_urlpatterns,
+    *settings_urlpatterns,
+    path("api/", include("netbox_librenms_plugin.api.urls")),
 ]
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@netbox_librenms_plugin/urls.py` around lines 92 - 557, The urlpatterns list
is too large—split it into domain-specific lists and then concatenate them into
urlpatterns to reduce merge conflicts; create separate lists like
sync_urlpatterns (containing paths referencing DeviceLibreNMSSyncView,
DeviceInterfaceTableView, SyncInterfacesView, SyncCablesView,
SyncIPAddressesView, SyncVLANsView, etc.), import_urlpatterns
(LibreNMSImportView, BulkImportDevicesView, BulkImportConfirmView,
DeviceValidationDetailsView, DeviceVCDetailsView, DeviceRoleUpdateView,
DeviceClusterUpdateView, DeviceRackUpdateView, DeviceConflictActionView),
mapping_urlpatterns (InterfaceTypeMappingListView, DeviceTypeMappingListView,
ModuleTypeMappingListView, ModuleBayMappingListView, NormalizationRuleListView
and their detail/create/edit/delete/changelog counterparts),
settings_urlpatterns (LibreNMSSettingsView, TestLibreNMSConnectionView,
SaveUserPrefView, DeviceStatusListView, VMStatusListView), and any other logical
groups (vm_urlpatterns, device_update_urlpatterns); then replace the big
urlpatterns with a single concatenation like urlpatterns = sync_urlpatterns +
import_urlpatterns + mapping_urlpatterns + settings_urlpatterns + [...] ensuring
existing path() entries and names (e.g., "device_librenms_sync",
"librenms_import", "interfacetypemapping_list") are moved unchanged so imports
and reverse URL lookups remain intact.
♻️ Duplicate comments (16)
netbox_librenms_plugin/views/sync/cables.py (1)

128-137: ⚠️ Potential issue | 🟡 Minor

Separate unexpected runtime failures from “invalid link data.”

Line 137 still classifies unexpected exceptions as invalid, but Lines 170-174 present invalid as “No LibreNMS link data found…”. This misreports runtime failures to users.

🔧 Proposed fix
-        results = {"valid": [], "invalid": [], "duplicate": [], "missing_remote": []}
+        results = {"valid": [], "invalid": [], "duplicate": [], "missing_remote": [], "failed": []}

         for interface in selected_interfaces:
             try:
                 with transaction.atomic():
                     result = self.process_single_interface(interface, cached_links)
                 results[result["status"]].append(result.get("interface", ""))
             except Exception:
                 logger.exception("Failed to sync cable for interface %s", interface.get("interface", ""))
-                results["invalid"].append(interface.get("interface", ""))
+                results["failed"].append(interface.get("interface", ""))
     def display_sync_results(self, request, results):
         """Display flash messages summarizing the cable sync results."""
+        if results["failed"]:
+            messages.error(
+                request,
+                f"Unexpected sync failure for interfaces: {', '.join(results['failed'])}. Check logs for details.",
+            )
         if results["missing_remote"]:
             messages.error(
                 request,
                 f"Remote device or interface not found in NetBox for: {', '.join(results['missing_remote'])}",
             )

Also applies to: 163-174

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

In `@netbox_librenms_plugin/views/sync/cables.py` around lines 128 - 137, The
except Exception handler is currently appending unexpected runtime failures to
results["invalid"], which conflates true "invalid link data" with runtime
errors; update the initial results dict to include a separate bucket (e.g.,
"error" or "failed") and change the except Exception blocks around the calls to
process_single_interface (the for-loop that wraps process_single_interface and
the similar block later) to append the interface identifier to that new bucket
instead of "invalid", while keeping logger.exception as-is so unexpected
exceptions are reported but not misclassified as invalid link data.
netbox_librenms_plugin/templates/netbox_librenms_plugin/inc/_module_sync.html (1)

26-26: ⚠️ Potential issue | 🔴 Critical

Fix the include path to the inc/ partial to avoid template resolution failure.

Line 26 points to netbox_librenms_plugin/_module_sync_content.html; this include should reference the inc/ location.

Suggested fix
-    {% include 'netbox_librenms_plugin/_module_sync_content.html' %}
+    {% include 'netbox_librenms_plugin/inc/_module_sync_content.html' %}

As per coding guidelines, "Reusable template includes should be placed under inc/ subdirectory."

🤖 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/inc/_module_sync.html`
at line 26, The template include currently references
'netbox_librenms_plugin/_module_sync_content.html' which will fail resolution;
update the include in the include statement to point to the inc/ partial (i.e.,
'netbox_librenms_plugin/inc/_module_sync_content.html') so the reusable partial
in the inc/ subdirectory is used; change the include line that mentions
_module_sync_content.html accordingly.
netbox_librenms_plugin/views/base/cables_view.py (1)

56-67: ⚠️ Potential issue | 🟡 Minor

Add an ifName fallback when the resolved interface field is missing.

On Line 64–66, rows are dropped if the selected field is absent; this can silently remove valid local-port mappings.

Suggested patch
-            port_name = port.get(interface_name_field)
-            if port_name is None:
+            port_name = port.get(interface_name_field) or port.get("ifName")
+            if port_name is None:
                 continue
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@netbox_librenms_plugin/views/base/cables_view.py` around lines 56 - 67, The
loop building local_ports_map currently drops rows when port_name is None;
modify the logic in the block that uses interface_name_field (the code around
get_interface_name_field, get_ports_data and local_ports_map) to attempt a
fallback lookup using port.get("ifName") before skipping the row, and only
continue if both the resolved field and the "ifName" fallback are missing;
ensure the final mapping uses the fallback value when used.
tests/e2e/test_module_install.py (3)

115-120: ⚠️ Potential issue | 🟡 Minor

Assert login success in the page fixture before yielding.

Without a post-login assertion (Line 115–120), auth failures show up later as unrelated test errors.

Suggested patch
 `@pytest.fixture`
 def page(browser):
     """Create a new page and log in to NetBox."""
+    from playwright.sync_api import expect
+
     ctx = browser.new_context(ignore_https_errors=True)
     pg = ctx.new_page()
@@
     pg.click("button[type=submit]")
     pg.wait_for_load_state("networkidle")
+    expect(pg).not_to_have_url(f"{NETBOX_URL}/login/")
+    expect(pg.locator("text=Log out")).to_be_visible()
     yield pg
     ctx.close()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/e2e/test_module_install.py` around lines 115 - 120, The fixture logs in
using pg.goto/fill/click but doesn't assert login succeeded, causing later tests
to fail cryptically; update the page fixture (the code using pg, NETBOX_URL,
NETBOX_USER, NETBOX_PASS) to verify post-login success before yielding by
waiting for and asserting a reliable post-login indicator (e.g.,
wait_for_selector or check pg.url contains the dashboard or a logout element)
and surface a clear error if authentication failed so tests stop early with a
meaningful message.

36-69: ⚠️ Potential issue | 🟠 Major

Harden Docker subprocess helpers with timeout and exit-code handling.

On Line 36 and Line 54, helper commands can hang or fail silently; this makes failures non-diagnostic and flaky.

Suggested patch
 def _get_container():
@@
-    result = subprocess.run(
-        ["docker", "ps", "--format", "{{.Names}}"],
-        capture_output=True,
-        text=True,
-    )
+    try:
+        result = subprocess.run(
+            ["docker", "ps", "--format", "{{.Names}}"],
+            capture_output=True,
+            text=True,
+            timeout=10,
+        )
+    except subprocess.TimeoutExpired:
+        pytest.skip("docker ps timed out")
+    if result.returncode != 0:
+        pytest.skip(f"docker ps failed: {result.stderr.strip() or 'unknown error'}")
@@
 def _netbox_shell(code):
@@
-    result = subprocess.run(
+    try:
+        result = subprocess.run(
         [
             "docker",
             "exec",
             container,
@@
         capture_output=True,
         text=True,
         env={"PATH": "/usr/bin:/bin", "HOME": "/root"},
-    )
+        timeout=60,
+        )
+    except subprocess.TimeoutExpired:
+        pytest.fail(f"docker exec timed out for container '{container}'")
+    if result.returncode != 0:
+        pytest.fail(
+            f"docker exec failed for container '{container}': "
+            f"{result.stderr.strip() or result.stdout.strip() or 'unknown error'}"
+        )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/e2e/test_module_install.py` around lines 36 - 69, The Docker helper
functions _get_container and _netbox_shell can hang or fail silently; update
their subprocess.run calls to include a timeout (e.g., timeout=30) and handle
non-zero exit codes by either using check=True or inspecting result.returncode
and raising a clear exception that includes result.stderr and result.stdout; in
_get_container also guard against empty stdout when splitting container names
before searching for "devcontainer-devcontainer" and call pytest.skip only after
verifying no container found. Ensure the env, capture_output, and text arguments
remain and propagate stderr in the raised error message for easier debugging.

133-139: 🧹 Nitpick | 🔵 Trivial

Replace fixed sleeps with deterministic Playwright waits.

On Line 133, Line 139, and the other listed segments, time.sleep(...) introduces avoidable flakiness and slowdowns.

Suggested patch pattern (apply similarly to all sleep sites)
     def _goto_modules_tab(self, page):
         """Navigate to the modules sync tab and refresh data."""
         page.goto(f"{NETBOX_URL}/dcim/devices/{self.DEVICE_ID}/librenms-sync/?tab=modules")
         page.wait_for_load_state("networkidle")
-        time.sleep(2)
+        page.wait_for_selector('button:has-text("Refresh Modules")')

         # Click Refresh Modules
         btn = page.query_selector('button:has-text("Refresh Modules")')
         assert btn is not None, "Refresh Modules button not found"
         btn.click()
-        time.sleep(8)
+        page.wait_for_load_state("networkidle")
+        page.wait_for_selector("#modules table tr")

Also applies to: 188-188, 215-217, 247-249, 286-287, 297-301, 312-317

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

In `@tests/e2e/test_module_install.py` around lines 133 - 139, Replace hard sleeps
with deterministic Playwright waits: instead of time.sleep(...) around
interactions that use page.query_selector and btn.click, wait for the UI state
change you expect (e.g., use page.wait_for_selector('button:has-text("Refresh
Modules")') before clicking, and after clicking use page.wait_for_selector or
page.wait_for_response or page.wait_for_load_state to wait for the modules
list/update indicator to appear or finish). Update each occurrence (the calls
around page.query_selector, btn.click and the other listed sleep sites) to use
the appropriate Playwright wait method (page.wait_for_selector,
page.wait_for_response, page.wait_for_load_state, or expect(...).to_be_visible)
targeting the specific selector or network response that indicates completion
rather than time.sleep.
netbox_librenms_plugin/templates/netbox_librenms_plugin/_module_sync_content.html (1)

1-31: ⚠️ Potential issue | 🟠 Major

Move this fragment into the HTMX templates directory.

This template is structured as a swapped content fragment; keeping it under templates/netbox_librenms_plugin/htmx/ is required for consistent HTMX response organization and target wiring.

Based on learnings, "Follow sync pipeline flow: ... render HTMX fragments in templates/netbox_librenms_plugin/htmx/."

🤖 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/_module_sync_content.html`
around lines 1 - 31, This fragment that renders the HTMX-swapped module sync
content (checks module_sync.table, uses module_sync.cache_expiry and elements
with ids module-cache-countdown and module-countdown-timer) should be moved from
templates/netbox_librenms_plugin/ to the HTMX templates directory; relocate the
file into templates/netbox_librenms_plugin/htmx/ and update any render/include
references that point to netbox_librenms_plugin/_module_sync_content.html to the
new path (e.g., netbox_librenms_plugin/htmx/_module_sync_content.html) so HTMX
responses follow the plugin's HTMX template organization and the target wiring
remains consistent.
netbox_librenms_plugin/views/sync/interfaces.py (1)

244-246: ⚠️ Potential issue | 🟠 Major

Guard MAC address sync with is_device_interface to prevent AttributeError on VMInterface objects.

handle_mac_address() accesses interface.mac_addresses and interface.primary_mac_address, which exist on dcim.Interface but not on virtualization.VMInterface. When syncing a VM interface with MAC address not excluded, this will raise an AttributeError. Apply the same is_device_interface guard used for ifType handling at line 222.

Proposed fix
-        if "mac_address" not in exclude_columns:
+        if "mac_address" not in exclude_columns and is_device_interface:
             ifPhysAddress = librenms_interface.get("ifPhysAddress")
             self.handle_mac_address(interface, ifPhysAddress)
🤖 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 244 - 246, The
MAC address sync can raise AttributeError for virtualization.VMInterface because
handle_mac_address() expects dcim.Interface attributes; wrap the existing block
that reads ifPhysAddress and calls self.handle_mac_address(interface,
ifPhysAddress) with the same is_device_interface guard used for ifType (i.e.,
only call handle_mac_address when is_device_interface(interface) is True and
"mac_address" not in exclude_columns), ensuring you reference
librenms_interface.get("ifPhysAddress") and the handle_mac_address method.
netbox_librenms_plugin/filters.py (1)

46-53: 🧹 Nitpick | 🔵 Trivial

manufacturer filter relies on implicit generation — consider explicit declaration for consistency.

The manufacturer field is a ForeignKey on NormalizationRule, so django-filters auto-generates a PK-based filter. While functional, adding an explicit ModelChoiceFilter would make the expected behavior clear and align with form patterns used elsewhere.

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

In `@netbox_librenms_plugin/filters.py` around lines 46 - 53, The manufacturer FK
filter is currently implicit; explicitly declare it in
NormalizationRuleFilterSet by adding a django_filters.ModelChoiceFilter named
manufacturer (e.g., manufacturer =
django_filters.ModelChoiceFilter(field_name="manufacturer",
queryset=Manufacturer.objects.all())) and import the Manufacturer model (or
derive it via NormalizationRule._meta.get_field('manufacturer').related_model)
so the filter behavior is explicit and consistent with other forms.
netbox_librenms_plugin/__init__.py (1)

82-85: ⚠️ Potential issue | 🟠 Major

Set _executed only after successful custom-field setup.

_executed is set on Line 84 before imports/DB operations. If anything in the try block fails, later post_migrate callbacks in the same process become no-ops, so the field may never be created.

🛠️ Proposed fix
 def _ensure_librenms_id_custom_field(sender, **kwargs):
@@
     if getattr(_ensure_librenms_id_custom_field, "_executed", False):
         return
-    _ensure_librenms_id_custom_field._executed = True  # not reset; see comment above
@@
     except Exception as e:
         # Don't break startup if custom field creation fails (e.g., during initial migration),
         # but log the error so it's not silently swallowed.
         logging.getLogger("netbox_librenms_plugin").exception("Failed to auto-create 'librenms_id' custom field: %s", e)
+        return
+
+    _ensure_librenms_id_custom_field._executed = True

Also applies to: 131-135

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

In `@netbox_librenms_plugin/__init__.py` around lines 82 - 85, The guard flag
_executed on _ensure_librenms_id_custom_field is being set before the
try/import/DB work, causing failures to suppress future post_migrate runs; move
setting _ensure_librenms_id_custom_field._executed = True to the end of the
function after the custom-field creation has completed successfully (i.e.,
inside the try block after all imports and DB operations succeed), and mirror
the same change for the similar block referenced at lines 131-135 so the flag is
only set on successful completion.
netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_validation_details.html (1)

23-23: ⚠️ Potential issue | 🟠 Major

Replace Bootstrap dismiss attributes with the project’s HTMX modal-close pattern.

Both close buttons still use data-bs-dismiss="modal", which bypasses the established HTMX modal wrapper flow in this plugin.

As per coding guidelines, "Modal buttons should target the htmx-modal-content element. JavaScript in librenms_import.html toggles the wrapper; do not reintroduce data-bs-toggle or duplicate modal IDs."

Also applies to: 562-563

🤖 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/htmx/device_validation_details.html`
at line 23, Remove the Bootstrap-specific data-bs-dismiss="modal" from the
<button class="btn-close"> elements and replace it with the plugin's HTMX
modal-close pattern so the wrapper JS (in librenms_import.html) is used to close
the modal; specifically, ensure the close buttons target the htmx-modal-content
element (by adding the project's modal-close attribute/class used elsewhere in
the plugin) and apply the same change to the other close button occurrences
mentioned.
netbox_librenms_plugin/views/base/modules_view.py (3)

93-96: ⚠️ Potential issue | 🟡 Minor

Treat cached empty inventory as a valid cache hit.

Line 94 uses a falsy check, so an intentionally cached empty list is treated as a miss.

🔧 Suggested fix
-        if not cached_data:
+        if cached_data is None:
             return {"table": None, "object": obj, "cache_expiry": None}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@netbox_librenms_plugin/views/base/modules_view.py` around lines 93 - 96, The
cache lookup in modules_view.py wrongly treats falsy cached values (like an
intentionally cached empty list) as a miss; update the check in the code that
calls cache.get(self.get_cache_key(obj, "inventory")) so it only treats a None
return as a cache miss (e.g., change the condition from "if not cached_data" to
"if cached_data is None"), then pass the cached value into
self._build_context(request, obj, cached_data) so empty inventories are handled
as valid cache hits; references: get_cache_key and _build_context in the modules
view.

516-527: ⚠️ Potential issue | 🟠 Major

Normalize FPC slot type before comparing to ModuleBay.position.

Line 519 keeps expected_fpc as string from regex, but parent_bay.position is numeric in NetBox models. This makes valid matches fail.

🐛 Suggested fix
-        expected_fpc = match.group(1)
+        try:
+            expected_fpc = int(match.group(1))
+        except ValueError:
+            return True
...
         return parent_bay.position == expected_fpc
#!/bin/bash
rg -n "expected_fpc = match.group\\(1\\)|parent_bay.position == expected_fpc" netbox_librenms_plugin/views/base/modules_view.py -C2
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@netbox_librenms_plugin/views/base/modules_view.py` around lines 516 - 527,
The comparison uses expected_fpc (string from match.group(1)) against
parent_bay.position (numeric); convert expected_fpc to an integer before
comparing and handle parse errors gracefully. Locate the block that defines
expected_fpc = match.group(1) and replace the direct comparison with code that
attempts to cast expected_fpc = int(expected_fpc) in a try/except (or
equivalent), returning True on ValueError/TypeError to preserve current
behavior, then return parent_bay.position == expected_fpc; keep the surrounding
checks for bay/module/module_bay unchanged.

994-1013: ⚠️ Potential issue | 🟠 Major

Parent-module resolution is over-restricted for nested branch installs.

Line 994 filters only module_id__isnull=True, so parent discovery misses installed modules under module-scoped bays. Also, Line 1007 should enforce exact-only mapping lookup with is_regex=False.

🧩 Suggested fix
-            device_bays = ModuleBay.objects.filter(device=device, module_id__isnull=True).select_related(
+            device_bays = ModuleBay.objects.filter(device=device).select_related(
                 "installed_module"
             )
...
-                mapping = ModuleBayMapping.objects.filter(librenms_name=name).first()
+                mapping = ModuleBayMapping.objects.filter(librenms_name=name, is_regex=False).first()
                 if mapping:
                     bay = (
-                        ModuleBay.objects.filter(device=device, name=mapping.netbox_bay_name, module_id__isnull=True)
+                        ModuleBay.objects.filter(device=device, name=mapping.netbox_bay_name)
                         .select_related("installed_module")
                         .first()
                     )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@netbox_librenms_plugin/views/base/modules_view.py` around lines 994 - 1013,
The parent-module resolution is too restrictive: remove the
module_id__isnull=True constraint so ModuleBay queries consider module-scoped
bays when discovering a parent (adjust both the initial device_bays query that
builds device_bays and the later ModuleBay lookup after finding a
ModuleBayMapping), and make the mapping lookup require exact matches by adding
is_regex=False to the ModuleBayMapping filter (i.e. filter(librenms_name=name,
is_regex=False)). Ensure you still use select_related("installed_module") when
loading bays so installed_module.pk can be returned.
netbox_librenms_plugin/views/imports/actions.py (2)

910-913: ⚠️ Potential issue | 🟠 Major

Escape untrusted values in HTMX error HTML responses.

Values like incoming_serial, conflict_device.name, librenms_os, hardware, and action are interpolated directly into HTML strings. These responses are HTMX-injected and can become XSS vectors.

🛡️ Suggested fix
+from django.utils.html import escape
...
-                    return HttpResponse(
-                        f"Serial conflict: '{incoming_serial}' is already assigned to device "
-                        f"'{conflict_device.name}' (ID: {conflict_device.pk})",
-                        status=409,
-                    )
+                    return HttpResponse(
+                        f"Serial conflict: '{escape(incoming_serial)}' is already assigned to device "
+                        f"'{escape(conflict_device.name)}' (ID: {conflict_device.pk})",
+                        status=409,
+                    )
...
-                    return HttpResponse(f"Platform '{librenms_os}' not found in NetBox", status=400)
+                    return HttpResponse(f"Platform '{escape(librenms_os)}' not found in NetBox", status=400)
...
-                return HttpResponse(f"No matching device type for '{hardware}'", status=400)
+                return HttpResponse(f"No matching device type for '{escape(hardware)}'", status=400)
...
-            return HttpResponse(f"Unknown action: {action}", status=400)
+            return HttpResponse(f"Unknown action: {escape(action)}", status=400)

Also applies to: 952-954, 974-976, 1025-1027, 1047-1047, 1062-1062, 1065-1065

🤖 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 910 - 913,
Several HttpResponse() calls in netbox_librenms_plugin/views/imports/actions.py
return HTMX-injected HTML built with f-strings containing untrusted values
(e.g., incoming_serial, id_conflict.name, librenms_os, hardware, action) which
can lead to XSS; replace those raw f-strings with Django-safe interpolations
using django.utils.html.format_html() or escape() (e.g., format_html("... ID {0}
...", escape(incoming_serial))) for each HttpResponse that currently injects
these variables (the LibreNMS ID conflict message and the other occurrences
around the reported ranges), ensuring the values are escaped before insertion
and preserving content_type='text/html' if needed.

857-880: ⚠️ Potential issue | 🔴 Critical

Enforce object-level authorization and bind conflict actions to the validated target.

Line 874 accepts existing_device_id from POST and mutates that object after only plugin-level permission checks. This allows crafted requests to target unrelated Device records.

🔒 Suggested fix
-from netbox_librenms_plugin.views.mixins import LibreNMSAPIMixin, LibreNMSPermissionMixin
+from netbox_librenms_plugin.views.mixins import (
+    LibreNMSAPIMixin,
+    LibreNMSPermissionMixin,
+    NetBoxObjectPermissionMixin,
+)
...
-class DeviceConflictActionView(LibreNMSPermissionMixin, LibreNMSAPIMixin, DeviceImportHelperMixin, View):
+class DeviceConflictActionView(
+    LibreNMSPermissionMixin, LibreNMSAPIMixin, NetBoxObjectPermissionMixin, DeviceImportHelperMixin, View
+):
...
     def post(self, request, device_id):
         """Resolve a device conflict by linking, updating, or syncing serial."""
         if error := self.require_write_permission():
             return error

         from dcim.models import Device
+        self.required_object_permissions = {"POST": [("change", Device)]}
+        if error := self.require_all_permissions_json("POST"):
+            return error
...
         libre_device, validation, selections = self.get_validated_device_with_selections(device_id, request)
         if not libre_device:
             return HttpResponse("LibreNMS device not found", status=404)
+        expected_existing = validation.get("existing_device")
+        if not expected_existing or expected_existing.pk != existing_device.pk:
+            return HttpResponse("existing_device_id does not match detected conflict target", status=400)

Also applies to: 873-877

🤖 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 857 - 880,
After calling get_validated_device_with_selections(...) and before mutating
existing_device, ensure you enforce object-level authorization and bind the
action to the validated target: verify that int(existing_device_id) equals
libre_device.pk or is one of the devices in selections (reject with 403 if not),
and then perform the mixin's object-level write permission check against that
existing_device (use the project’s existing object-permission helper from
LibreNMSPermissionMixin or equivalent) before making any changes in
DeviceConflictActionView.post.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@docs/usage_tips/permissions.md`:
- Line 29: Fix the numbering inconsistency in the permissions list: change the
second list item that currently reads "1. **Tier 2: Object permission**: User
needs `dcim.add_device`..." to use "2." so the ordered list correctly continues
after "1. **Tier 1: Plugin permission**". Ensure the displayed text "Tier 2:
Object permission" and permission `dcim.add_device` remain unchanged aside from
the numeric prefix.

In `@netbox_librenms_plugin/forms.py`:
- Line 397: The form field is using the deprecated NullBooleanField for
is_regex; replace it with a forward-compatible alternative such as
forms.TypedChoiceField(...) or forms.BooleanField(required=False) to avoid
breakage in Django >=4.0—update the is_regex declaration in the form (look for
the is_regex assignment in the form class) to use TypedChoiceField with explicit
coerce and choices to represent None/True/False or use
BooleanField(required=False) if tri-state is not needed, and adjust any form
cleaning/consumption code that expects None/True/False accordingly (e.g.,
clean_is_regex or places referencing form.cleaned_data['is_regex']).

In `@netbox_librenms_plugin/import_utils/bulk_import.py`:
- Around line 426-430: The early-exit branches in process_device_filters()
currently return a bare empty list on connection/error or when request is
truthy, breaking the declared tuple return type when return_cache_status=True;
update those return statements inside the except blocks and any other early
returns (including the similar branches around the blocks referenced) to return
a two-tuple of ([], False) when the function was called with
return_cache_status=True, otherwise keep returning [] to preserve previous
behavior—i.e., check the return_cache_status parameter in the except handlers in
process_device_filters() and return ([], False) instead of [] for the tuple
contract, and apply the same change to the other noted early-exit branches in
the same function.

In `@netbox_librenms_plugin/import_utils/cache.py`:
- Around line 74-78: The code assumes metadata.get("cached_at") is always
present and ISO-formatted; wrap parsing of cached_at in a guard and fallback:
check if metadata.get("cached_at") is truthy, attempt
datetime.fromisoformat(...) inside a try/except (catch ValueError/TypeError),
and on failure set cached_at to a safe fallback (e.g.,
datetime.now(timezone.utc) minus cache_timeout seconds or
datetime.fromtimestamp(0, timezone.utc) to force expired) so the rest of the
logic (age_seconds, remaining_seconds) does not raise; update references to
cached_at, cache_timeout, and remaining_seconds accordingly so invalid or
missing metadata yields an expired cache instead of crashing.
- Around line 133-136: The code currently uses Python's randomized hash() on
sorted(filters.items()), causing non-deterministic keys across processes;
replace that with a deterministic digest (e.g. use hashlib.sha256 on a stable
serialization of filters) so filter_hash is computed deterministically across
processes. Specifically, serialize the filters in a stable way (JSON with sorted
keys or joining sorted items), compute a hex digest (sha256.hexdigest() or
truncated portion) and assign that to filter_hash, leaving vc_part, server_key,
and device_id usage and the final
f"validated_device_{server_key}_{filter_hash}_{device_id}_{vc_part}" return
intact.

In `@netbox_librenms_plugin/import_utils/device_operations.py`:
- Around line 266-271: The code forces VM mode by setting result["import_as_vm"]
when an existing VM is found but later branches still read the original
import_as_vm parameter, causing contradictory validation; fix by making
downstream logic read a single source-of-truth (result["import_as_vm"]) after
any modifications: after you set result["import_as_vm"] = True in the
existing-VM branch, assign a local variable (e.g., effective_import_as_vm =
result["import_as_vm"] or simply reference result["import_as_vm"] everywhere)
and replace all subsequent checks that use the original import_as_vm parameter
with checks against result["import_as_vm"] (also update the same pattern at the
other occurrences referenced around the import/validation logic to ensure
consistency).

In `@netbox_librenms_plugin/import_utils/filters.py`:
- Line 174: The cache key generation in get_librenms_devices_for_import
currently uses Python's built-in hash() and the literal server_key which can be
"None"; change it to build a deterministic, server-aware key by using the actual
server identifier (use api.server_key when server_key is None) and replace
hash(str(...)) with a stable digest (e.g., compute a hex digest from a
canonicalized representation of api_filters and client_filters using hashlib
like sha256 over json.dumps(..., sort_keys=True) or repr(... )). Also update the
identical pattern in get_validated_device_cache_key in import_utils/cache.py so
both functions produce stable, cross-process cache keys and avoid collisions
across servers.

In `@netbox_librenms_plugin/import_utils/permissions.py`:
- Around line 1-9: The module defines an unused logger variable (logger =
logging.getLogger(__name__)) which should be removed to avoid dead code; either
delete the logger import and the logger assignment or, if you intend to use it
later, add a brief comment above logger explaining its planned use (e.g., "#
reserved for future logging in permission checks") so linters won't flag
it—update imports accordingly by removing the logging import if deleting the
logger.

In `@netbox_librenms_plugin/import_utils/virtual_chassis.py`:
- Around line 375-379: The loop over members_info currently skips entries when
member.get("serial") equals master_device.serial and also treats blank serials
as matches, and later code rewrites slot positions sequentially; update the
logic in the loop that iterates members_info so it only skips a member if
member.get("serial") is truthy and exactly equals master_device.serial (i.e.,
check for a non-empty serial before comparing), and when creating/updating
member records (the code paths around where Member objects/positions are
created/updated following members_info processing) preserve the original
member["position"] from members_info instead of assigning incremental slot
numbers; apply the same fix to the analogous blocks referenced (the later
sections handling member creation/positioning around the 392-415 and 417-418
areas) so blank serials are not treated as the master and detected positions are
retained.

In `@netbox_librenms_plugin/tables/device_status.py`:
- Around line 489-496: The details button markup currently relies on title and
icon-only visuals when btn_label is empty, which is inaccessible; update the
button rendering (the string built using details_url, device_id, btn_class,
btn_icon, btn_label, btn_title) to add an aria-label attribute when btn_label is
falsy—use btn_title (or another descriptive string) for aria-label so screen
readers announce the button consistently while preserving the existing title
attribute and behavior.

In `@netbox_librenms_plugin/tables/interfaces.py`:
- Line 16: Replace direct use of the helper get_librenms_device_id(...) with the
official accessor LibreNMSAPI.get_librenms_id(...) in the interfaces resolution
flow (where get_librenms_device_id is currently invoked); update imports to
remove the direct helper and ensure LibreNMSAPI is imported/available, call the
API accessor with the same arguments and handle its return value identically
(including None/exception cases) so the librenms_id mapping is retrieved via
LibreNMSAPI.get_librenms_id rather than touching the custom-field helper
directly.

In `@netbox_librenms_plugin/tables/modules.py`:
- Around line 121-128: The tooltip text currently contains a literal
"{module_path}" instead of the actual module_path value; update the format_html
call so the final string uses a placeholder consumed by format_html and pass
module_path as the last argument (i.e., change the title argument from the
literal "Upgrade NetBox to fully support {module_path}" to a format placeholder
like "Upgrade NetBox to fully support {}" and pass module_path alongside
badge_class, warning, value) so the module_path variable is rendered into the
icon title; refer to the variables badge_class, warning, value and module_path
in the format_html call.

In `@netbox_librenms_plugin/tests/test_init.py`:
- Around line 129-144: In the test_exception_does_not_propagate test, after
invoking _ensure_librenms_id_custom_field(sender=None) and verifying
logger.exception was called, add an assertion checking the function-level flag
_ensure_librenms_id_custom_field._executed to ensure it reflects the one-shot
execution state (e.g., assert _ensure_librenms_id_custom_field._executed is
True) so failed attempts don't silently allow retries.
- Around line 46-57: Update the test assertion to match the new default
custom-field type: change the expected "type" value from "integer" to "json" in
the MockCustomField.objects.get_or_create assertion inside the test for
_ensure_librenms_id_custom_field so the defaults dict reflects "type": "json"
while keeping all other keys unchanged.

In `@netbox_librenms_plugin/views/base/librenms_sync_view.py`:
- Around line 235-237: Remove the redundant assignment to found_in_librenms (the
second "found_in_librenms = True") in the block where you set mismatched_device
= True; leave the mismatched_device assignment and the explanatory comment but
delete the duplicate found_in_librenms = True so the code only sets
found_in_librenms once (the earlier assignment after the successful API call).

In `@netbox_librenms_plugin/views/imports/actions.py`:
- Around line 903-908: The current id_conflict query in the import action
(variable id_conflict in netbox_librenms_plugin.views.imports.actions) only
checks the legacy custom_field_data__librenms_id=int(librenms_id) and misses the
new per-server dict shape; update the Device.objects.filter(...) used to set
id_conflict to check both shapes by OR-ing a Q for the legacy integer lookup
with a Q that checks the per-server dict contains the server key mapping to the
same int(librenms_id) (e.g. custom_field_data__librenms_id__contains={server:
int(librenms_id)}), keep the .exclude(pk=existing_device.pk).first() behavior
and add the necessary Q import. Ensure you reference the same librenms_id and
existing_device variables used currently.

---

Outside diff comments:
In `@netbox_librenms_plugin/urls.py`:
- Around line 92-557: The urlpatterns list is too large—split it into
domain-specific lists and then concatenate them into urlpatterns to reduce merge
conflicts; create separate lists like sync_urlpatterns (containing paths
referencing DeviceLibreNMSSyncView, DeviceInterfaceTableView,
SyncInterfacesView, SyncCablesView, SyncIPAddressesView, SyncVLANsView, etc.),
import_urlpatterns (LibreNMSImportView, BulkImportDevicesView,
BulkImportConfirmView, DeviceValidationDetailsView, DeviceVCDetailsView,
DeviceRoleUpdateView, DeviceClusterUpdateView, DeviceRackUpdateView,
DeviceConflictActionView), mapping_urlpatterns (InterfaceTypeMappingListView,
DeviceTypeMappingListView, ModuleTypeMappingListView, ModuleBayMappingListView,
NormalizationRuleListView and their detail/create/edit/delete/changelog
counterparts), settings_urlpatterns (LibreNMSSettingsView,
TestLibreNMSConnectionView, SaveUserPrefView, DeviceStatusListView,
VMStatusListView), and any other logical groups (vm_urlpatterns,
device_update_urlpatterns); then replace the big urlpatterns with a single
concatenation like urlpatterns = sync_urlpatterns + import_urlpatterns +
mapping_urlpatterns + settings_urlpatterns + [...] ensuring existing path()
entries and names (e.g., "device_librenms_sync", "librenms_import",
"interfacetypemapping_list") are moved unchanged so imports and reverse URL
lookups remain intact.

---

Duplicate comments:
In `@netbox_librenms_plugin/__init__.py`:
- Around line 82-85: The guard flag _executed on
_ensure_librenms_id_custom_field is being set before the try/import/DB work,
causing failures to suppress future post_migrate runs; move setting
_ensure_librenms_id_custom_field._executed = True to the end of the function
after the custom-field creation has completed successfully (i.e., inside the try
block after all imports and DB operations succeed), and mirror the same change
for the similar block referenced at lines 131-135 so the flag is only set on
successful completion.

In `@netbox_librenms_plugin/filters.py`:
- Around line 46-53: The manufacturer FK filter is currently implicit;
explicitly declare it in NormalizationRuleFilterSet by adding a
django_filters.ModelChoiceFilter named manufacturer (e.g., manufacturer =
django_filters.ModelChoiceFilter(field_name="manufacturer",
queryset=Manufacturer.objects.all())) and import the Manufacturer model (or
derive it via NormalizationRule._meta.get_field('manufacturer').related_model)
so the filter behavior is explicit and consistent with other forms.

In
`@netbox_librenms_plugin/templates/netbox_librenms_plugin/_module_sync_content.html`:
- Around line 1-31: This fragment that renders the HTMX-swapped module sync
content (checks module_sync.table, uses module_sync.cache_expiry and elements
with ids module-cache-countdown and module-countdown-timer) should be moved from
templates/netbox_librenms_plugin/ to the HTMX templates directory; relocate the
file into templates/netbox_librenms_plugin/htmx/ and update any render/include
references that point to netbox_librenms_plugin/_module_sync_content.html to the
new path (e.g., netbox_librenms_plugin/htmx/_module_sync_content.html) so HTMX
responses follow the plugin's HTMX template organization and the target wiring
remains consistent.

In
`@netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_validation_details.html`:
- Line 23: Remove the Bootstrap-specific data-bs-dismiss="modal" from the
<button class="btn-close"> elements and replace it with the plugin's HTMX
modal-close pattern so the wrapper JS (in librenms_import.html) is used to close
the modal; specifically, ensure the close buttons target the htmx-modal-content
element (by adding the project's modal-close attribute/class used elsewhere in
the plugin) and apply the same change to the other close button occurrences
mentioned.

In
`@netbox_librenms_plugin/templates/netbox_librenms_plugin/inc/_module_sync.html`:
- Line 26: The template include currently references
'netbox_librenms_plugin/_module_sync_content.html' which will fail resolution;
update the include in the include statement to point to the inc/ partial (i.e.,
'netbox_librenms_plugin/inc/_module_sync_content.html') so the reusable partial
in the inc/ subdirectory is used; change the include line that mentions
_module_sync_content.html accordingly.

In `@netbox_librenms_plugin/views/base/cables_view.py`:
- Around line 56-67: The loop building local_ports_map currently drops rows when
port_name is None; modify the logic in the block that uses interface_name_field
(the code around get_interface_name_field, get_ports_data and local_ports_map)
to attempt a fallback lookup using port.get("ifName") before skipping the row,
and only continue if both the resolved field and the "ifName" fallback are
missing; ensure the final mapping uses the fallback value when used.

In `@netbox_librenms_plugin/views/base/modules_view.py`:
- Around line 93-96: The cache lookup in modules_view.py wrongly treats falsy
cached values (like an intentionally cached empty list) as a miss; update the
check in the code that calls cache.get(self.get_cache_key(obj, "inventory")) so
it only treats a None return as a cache miss (e.g., change the condition from
"if not cached_data" to "if cached_data is None"), then pass the cached value
into self._build_context(request, obj, cached_data) so empty inventories are
handled as valid cache hits; references: get_cache_key and _build_context in the
modules view.
- Around line 516-527: The comparison uses expected_fpc (string from
match.group(1)) against parent_bay.position (numeric); convert expected_fpc to
an integer before comparing and handle parse errors gracefully. Locate the block
that defines expected_fpc = match.group(1) and replace the direct comparison
with code that attempts to cast expected_fpc = int(expected_fpc) in a try/except
(or equivalent), returning True on ValueError/TypeError to preserve current
behavior, then return parent_bay.position == expected_fpc; keep the surrounding
checks for bay/module/module_bay unchanged.
- Around line 994-1013: The parent-module resolution is too restrictive: remove
the module_id__isnull=True constraint so ModuleBay queries consider
module-scoped bays when discovering a parent (adjust both the initial
device_bays query that builds device_bays and the later ModuleBay lookup after
finding a ModuleBayMapping), and make the mapping lookup require exact matches
by adding is_regex=False to the ModuleBayMapping filter (i.e.
filter(librenms_name=name, is_regex=False)). Ensure you still use
select_related("installed_module") when loading bays so installed_module.pk can
be returned.

In `@netbox_librenms_plugin/views/imports/actions.py`:
- Around line 910-913: Several HttpResponse() calls in
netbox_librenms_plugin/views/imports/actions.py return HTMX-injected HTML built
with f-strings containing untrusted values (e.g., incoming_serial,
id_conflict.name, librenms_os, hardware, action) which can lead to XSS; replace
those raw f-strings with Django-safe interpolations using
django.utils.html.format_html() or escape() (e.g., format_html("... ID {0} ...",
escape(incoming_serial))) for each HttpResponse that currently injects these
variables (the LibreNMS ID conflict message and the other occurrences around the
reported ranges), ensuring the values are escaped before insertion and
preserving content_type='text/html' if needed.
- Around line 857-880: After calling get_validated_device_with_selections(...)
and before mutating existing_device, ensure you enforce object-level
authorization and bind the action to the validated target: verify that
int(existing_device_id) equals libre_device.pk or is one of the devices in
selections (reject with 403 if not), and then perform the mixin's object-level
write permission check against that existing_device (use the project’s existing
object-permission helper from LibreNMSPermissionMixin or equivalent) before
making any changes in DeviceConflictActionView.post.

In `@netbox_librenms_plugin/views/sync/cables.py`:
- Around line 128-137: The except Exception handler is currently appending
unexpected runtime failures to results["invalid"], which conflates true "invalid
link data" with runtime errors; update the initial results dict to include a
separate bucket (e.g., "error" or "failed") and change the except Exception
blocks around the calls to process_single_interface (the for-loop that wraps
process_single_interface and the similar block later) to append the interface
identifier to that new bucket instead of "invalid", while keeping
logger.exception as-is so unexpected exceptions are reported but not
misclassified as invalid link data.

In `@netbox_librenms_plugin/views/sync/interfaces.py`:
- Around line 244-246: The MAC address sync can raise AttributeError for
virtualization.VMInterface because handle_mac_address() expects dcim.Interface
attributes; wrap the existing block that reads ifPhysAddress and calls
self.handle_mac_address(interface, ifPhysAddress) with the same
is_device_interface guard used for ifType (i.e., only call handle_mac_address
when is_device_interface(interface) is True and "mac_address" not in
exclude_columns), ensuring you reference librenms_interface.get("ifPhysAddress")
and the handle_mac_address method.

In `@tests/e2e/test_module_install.py`:
- Around line 115-120: The fixture logs in using pg.goto/fill/click but doesn't
assert login succeeded, causing later tests to fail cryptically; update the page
fixture (the code using pg, NETBOX_URL, NETBOX_USER, NETBOX_PASS) to verify
post-login success before yielding by waiting for and asserting a reliable
post-login indicator (e.g., wait_for_selector or check pg.url contains the
dashboard or a logout element) and surface a clear error if authentication
failed so tests stop early with a meaningful message.
- Around line 36-69: The Docker helper functions _get_container and
_netbox_shell can hang or fail silently; update their subprocess.run calls to
include a timeout (e.g., timeout=30) and handle non-zero exit codes by either
using check=True or inspecting result.returncode and raising a clear exception
that includes result.stderr and result.stdout; in _get_container also guard
against empty stdout when splitting container names before searching for
"devcontainer-devcontainer" and call pytest.skip only after verifying no
container found. Ensure the env, capture_output, and text arguments remain and
propagate stderr in the raised error message for easier debugging.
- Around line 133-139: Replace hard sleeps with deterministic Playwright waits:
instead of time.sleep(...) around interactions that use page.query_selector and
btn.click, wait for the UI state change you expect (e.g., use
page.wait_for_selector('button:has-text("Refresh Modules")') before clicking,
and after clicking use page.wait_for_selector or page.wait_for_response or
page.wait_for_load_state to wait for the modules list/update indicator to appear
or finish). Update each occurrence (the calls around page.query_selector,
btn.click and the other listed sleep sites) to use the appropriate Playwright
wait method (page.wait_for_selector, page.wait_for_response,
page.wait_for_load_state, or expect(...).to_be_visible) targeting the specific
selector or network response that indicates completion rather than time.sleep.

ℹ️ Review info

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

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between f09628c and 4ecce10.

📒 Files selected for processing (79)
  • .devcontainer/README.md
  • .devcontainer/scripts/diagnose.sh
  • .devcontainer/scripts/load-aliases.sh
  • .devcontainer/scripts/setup.sh
  • .devcontainer/scripts/start-netbox.sh
  • .devcontainer/scripts/welcome.sh
  • .github/workflows/lint-format.yaml
  • contrib/README.md
  • contrib/device_type_mappings.yaml
  • contrib/interface_name_rules.yaml
  • contrib/interface_type_mappings.yaml
  • contrib/module_bay_mappings.yaml
  • contrib/module_type_mappings.yaml
  • contrib/normalization_rules.yaml
  • docs/usage_tips/custom_field.md
  • docs/usage_tips/permissions.md
  • netbox_librenms_plugin/__init__.py
  • netbox_librenms_plugin/api/serializers.py
  • netbox_librenms_plugin/api/urls.py
  • netbox_librenms_plugin/api/views.py
  • netbox_librenms_plugin/filters.py
  • netbox_librenms_plugin/forms.py
  • netbox_librenms_plugin/import_utils.py
  • netbox_librenms_plugin/import_utils/__init__.py
  • netbox_librenms_plugin/import_utils/bulk_import.py
  • netbox_librenms_plugin/import_utils/cache.py
  • netbox_librenms_plugin/import_utils/device_operations.py
  • netbox_librenms_plugin/import_utils/filters.py
  • netbox_librenms_plugin/import_utils/permissions.py
  • netbox_librenms_plugin/import_utils/virtual_chassis.py
  • netbox_librenms_plugin/import_utils/vm_operations.py
  • netbox_librenms_plugin/librenms_api.py
  • netbox_librenms_plugin/migrations/0009_add_devicetypemapping.py
  • netbox_librenms_plugin/migrations/0010_add_moduletypemapping.py
  • netbox_librenms_plugin/migrations/0011_modulebaymapping.py
  • netbox_librenms_plugin/migrations/0012_add_is_regex_to_modulebaymapping.py
  • netbox_librenms_plugin/migrations/0013_normalizationrule.py
  • netbox_librenms_plugin/models.py
  • netbox_librenms_plugin/navigation.py
  • 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/tables/mappings.py
  • netbox_librenms_plugin/tables/modules.py
  • netbox_librenms_plugin/templates/netbox_librenms_plugin/_module_sync_content.html
  • netbox_librenms_plugin/templates/netbox_librenms_plugin/devicetypemapping.html
  • netbox_librenms_plugin/templates/netbox_librenms_plugin/devicetypemapping_list.html
  • netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_validation_details.html
  • netbox_librenms_plugin/templates/netbox_librenms_plugin/inc/_module_sync.html
  • netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html
  • netbox_librenms_plugin/templates/netbox_librenms_plugin/modulebaymapping.html
  • netbox_librenms_plugin/templates/netbox_librenms_plugin/modulebaymapping_list.html
  • netbox_librenms_plugin/templates/netbox_librenms_plugin/moduletypemapping.html
  • netbox_librenms_plugin/templates/netbox_librenms_plugin/moduletypemapping_list.html
  • netbox_librenms_plugin/templates/netbox_librenms_plugin/normalizationrule.html
  • netbox_librenms_plugin/templates/netbox_librenms_plugin/normalizationrule_list.html
  • netbox_librenms_plugin/tests/test_import_utils.py
  • netbox_librenms_plugin/tests/test_init.py
  • netbox_librenms_plugin/tests/test_permissions.py
  • netbox_librenms_plugin/tests/test_utils.py
  • netbox_librenms_plugin/urls.py
  • netbox_librenms_plugin/utils.py
  • netbox_librenms_plugin/views/__init__.py
  • netbox_librenms_plugin/views/base/cables_view.py
  • netbox_librenms_plugin/views/base/ip_addresses_view.py
  • netbox_librenms_plugin/views/base/librenms_sync_view.py
  • netbox_librenms_plugin/views/base/modules_view.py
  • netbox_librenms_plugin/views/imports/actions.py
  • netbox_librenms_plugin/views/mapping_views.py
  • netbox_librenms_plugin/views/object_sync/__init__.py
  • netbox_librenms_plugin/views/object_sync/devices.py
  • netbox_librenms_plugin/views/object_sync/vms.py
  • netbox_librenms_plugin/views/sync/cables.py
  • netbox_librenms_plugin/views/sync/device_fields.py
  • netbox_librenms_plugin/views/sync/devices.py
  • netbox_librenms_plugin/views/sync/interfaces.py
  • tests/e2e/__init__.py
  • tests/e2e/conftest.py
  • tests/e2e/test_module_install.py
💤 Files with no reviewable changes (1)
  • netbox_librenms_plugin/import_utils.py

The Plugin also enforces Netbox object permissions so the following permission would also be required:

2. **Tier 2: Object permission**: User needs `dcim.add_device` (to create the device in NetBox)
1. **Tier 2: Object permission**: User needs `dcim.add_device` (to create the device in NetBox)

ghost Mar 1, 2026

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 | 🟡 Minor

Numbering inconsistency in the permission tiers list.

Line 22 shows "1. Tier 1: Plugin permission" and this line shows "1. Tier 2: Object permission". Both items being numbered "1." breaks the list structure. This should likely remain "2." to properly continue the numbered list describing the two-tier permission model.

📝 Suggested fix
-1. **Tier 2: Object permission**: User needs `dcim.add_device` (to create the device in NetBox)
+2. **Tier 2: Object permission**: User needs `dcim.add_device` (to create the device in NetBox)
📝 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
1. **Tier 2: Object permission**: User needs `dcim.add_device` (to create the device in NetBox)
2. **Tier 2: Object permission**: User needs `dcim.add_device` (to create the device in NetBox)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/usage_tips/permissions.md` at line 29, Fix the numbering inconsistency
in the permissions list: change the second list item that currently reads "1.
**Tier 2: Object permission**: User needs `dcim.add_device`..." to use "2." so
the ordered list correctly continues after "1. **Tier 1: Plugin permission**".
Ensure the displayed text "Tier 2: Object permission" and permission
`dcim.add_device` remain unchanged aside from the numeric prefix.

librenms_name = forms.CharField(required=False, label="LibreNMS Name")
librenms_class = forms.CharField(required=False, label="LibreNMS Class")
netbox_bay_name = forms.CharField(required=False, label="NetBox Bay Name")
is_regex = forms.NullBooleanField(required=False, label="Regex")

ghost Mar 1, 2026

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Version constraints (pyproject.toml, requirements, etc.) =="
find . -maxdepth 2 -type f \( -name "pyproject.toml" -o -name "requirements*.txt" -o -name "setup.py" -o -name "setup.cfg" -o -name "poetry.lock" \) | head -20

echo
echo "== Checking pyproject.toml for Django/NetBox versions =="
if [ -f "pyproject.toml" ]; then
  cat pyproject.toml
fi

echo
echo "== Checking setup.py for Django/NetBox versions =="
if [ -f "setup.py" ]; then
  cat setup.py
fi

Repository: marcinpsk/netbox-librenms-plugin

Length of output: 1939


🏁 Script executed:

echo "== NullBooleanField usages in repository =="
rg -n -C2 'NullBooleanField' --type py

Repository: marcinpsk/netbox-librenms-plugin

Length of output: 554


🏁 Script executed:

echo "== Check Django version imports/comments =="
rg -n 'django.*version|Django.*version|DJANGO_VERSION' --type py

Repository: marcinpsk/netbox-librenms-plugin

Length of output: 119


🏁 Script executed:

echo "== Check README for NetBox/Django version requirements =="
if [ -f "README.md" ]; then
  head -100 README.md
fi

echo
echo "== Check for NetBox version compatibility info =="
rg -n 'netbox|NetBox' --type md | head -20

Repository: marcinpsk/netbox-librenms-plugin

Length of output: 6832


🏁 Script executed:

echo "== Check if there's any Django/NetBox import or version check =="
rg -n 'from django' netbox_librenms_plugin/forms.py | head -10

echo
echo "== Check full forms.py to understand context =="
head -50 netbox_librenms_plugin/forms.py

Repository: marcinpsk/netbox-librenms-plugin

Length of output: 1910


Consider replacing deprecated NullBooleanField with an alternative field for forward compatibility.

NullBooleanField is deprecated in Django 3.14 and removed in Django 4.0+. Since this plugin has no explicit Django version constraint in pyproject.toml, it may eventually run against a Django version that no longer provides this field. Replace with TypedChoiceField or BooleanField with required=False to future-proof the code.

Suggested replacement
-    is_regex = forms.NullBooleanField(required=False, label="Regex")
+    is_regex = forms.TypedChoiceField(
+        required=False,
+        label="Regex",
+        choices=(("", "---------"), ("true", "Yes"), ("false", "No")),
+        coerce=lambda v: {"true": True, "false": False}.get(v, None),
+        empty_value=None,
+    )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@netbox_librenms_plugin/forms.py` at line 397, The form field is using the
deprecated NullBooleanField for is_regex; replace it with a forward-compatible
alternative such as forms.TypedChoiceField(...) or
forms.BooleanField(required=False) to avoid breakage in Django >=4.0—update the
is_regex declaration in the form (look for the is_regex assignment in the form
class) to use TypedChoiceField with explicit coerce and choices to represent
None/True/False or use BooleanField(required=False) if tri-state is not needed,
and adjust any form cleaning/consumption code that expects None/True/False
accordingly (e.g., clean_is_regex or places referencing
form.cleaned_data['is_regex']).

Comment thread netbox_librenms_plugin/import_utils/bulk_import.py
Comment thread netbox_librenms_plugin/import_utils/cache.py Outdated
Comment thread netbox_librenms_plugin/import_utils/cache.py Outdated
convert_speed_to_kbps,
format_mac_address,
get_interface_name_field,
get_librenms_device_id,

ghost Mar 1, 2026

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

Use LibreNMSAPI.get_librenms_id here instead of direct custom-field helper access.

Line 365 now resolves IDs via get_librenms_device_id(...), but this flow bypasses the required API accessor path defined for librenms_id mapping retrieval. Please switch this call to LibreNMSAPI.get_librenms_id(...).

As per coding guidelines: "Always call LibreNMSAPI.get_librenms_id to retrieve the device/VM LibreNMS mapping via the librenms_id custom field instead of touching the field directly".

Also applies to: 365-365

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

In `@netbox_librenms_plugin/tables/interfaces.py` at line 16, Replace direct use
of the helper get_librenms_device_id(...) with the official accessor
LibreNMSAPI.get_librenms_id(...) in the interfaces resolution flow (where
get_librenms_device_id is currently invoked); update imports to remove the
direct helper and ensure LibreNMSAPI is imported/available, call the API
accessor with the same arguments and handle its return value identically
(including None/exception cases) so the librenms_id mapping is retrieved via
LibreNMSAPI.get_librenms_id rather than touching the custom-field helper
directly.

Comment on lines +121 to +128
return format_html(
'<span class="badge {}" title="{}">{}</span>'
' <i class="mdi mdi-alert-outline text-warning" title="{}"></i>',
badge_class,
warning,
value,
"Upgrade NetBox to fully support {module_path}",
)

ghost Mar 1, 2026

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 | 🟡 Minor

Fix the unrendered {module_path} placeholder in the warning tooltip.

At Line 127, the icon title is a literal string ("Upgrade NetBox to fully support {module_path}"), so users won’t see contextual detail.

💡 Suggested fix
         if warning := record.get("module_path_warning"):
             return format_html(
                 '<span class="badge {}" title="{}">{}</span>'
                 ' <i class="mdi mdi-alert-outline text-warning" title="{}"></i>',
                 badge_class,
                 warning,
                 value,
-                "Upgrade NetBox to fully support {module_path}",
+                warning,
             )
📝 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
return format_html(
'<span class="badge {}" title="{}">{}</span>'
' <i class="mdi mdi-alert-outline text-warning" title="{}"></i>',
badge_class,
warning,
value,
"Upgrade NetBox to fully support {module_path}",
)
return format_html(
'<span class="badge {}" title="{}">{}</span>'
' <i class="mdi mdi-alert-outline text-warning" title="{}"></i>',
badge_class,
warning,
value,
warning,
)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@netbox_librenms_plugin/tables/modules.py` around lines 121 - 128, The tooltip
text currently contains a literal "{module_path}" instead of the actual
module_path value; update the format_html call so the final string uses a
placeholder consumed by format_html and pass module_path as the last argument
(i.e., change the title argument from the literal "Upgrade NetBox to fully
support {module_path}" to a format placeholder like "Upgrade NetBox to fully
support {}" and pass module_path alongside badge_class, warning, value) so the
module_path variable is rendered into the icon title; refer to the variables
badge_class, warning, value and module_path in the format_html call.

Comment thread netbox_librenms_plugin/tests/test_init.py
Comment on lines +129 to +144
def test_exception_does_not_propagate(self, MockCustomField):
"""Exceptions during custom field creation are caught and logged."""
from netbox_librenms_plugin import _ensure_librenms_id_custom_field

MockCustomField.objects.get_or_create.side_effect = Exception("DB not ready")

with patch("logging.getLogger") as mock_get_logger:
# Should not raise
_ensure_librenms_id_custom_field(sender=None)

# Verify the exception was logged
logger_instance = mock_get_logger.return_value
logger_instance.exception.assert_called_once()
call_args = logger_instance.exception.call_args
assert "librenms_id" in call_args[0][0]

ghost Mar 1, 2026

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

Add a retry-semantics assertion after exception handling.

test_exception_does_not_propagate should also assert _ensure_librenms_id_custom_field._executed state after failure, so one-shot failure behavior can’t regress silently.

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

In `@netbox_librenms_plugin/tests/test_init.py` around lines 129 - 144, In the
test_exception_does_not_propagate test, after invoking
_ensure_librenms_id_custom_field(sender=None) and verifying logger.exception was
called, add an assertion checking the function-level flag
_ensure_librenms_id_custom_field._executed to ensure it reflects the one-shot
execution state (e.g., assert _ensure_librenms_id_custom_field._executed is
True) so failed attempts don't silently allow retries.

Comment thread netbox_librenms_plugin/views/base/librenms_sync_view.py
Marcin Zieba added 2 commits March 1, 2026 23:29
- Add migrate_legacy_librenms_id(obj, server_key) helper in utils.py
  Converts a bare-integer librenms_id to {server_key: int_value}.
  Returns True if migration happened, False if already JSON or absent.
  Does not call save() — caller is responsible.

- Detect legacy int format in validate_device_for_import()
  When find_by_librenms_id matches a Device or VM whose librenms_id CF
  is still a bare integer, set result['librenms_id_needs_migration']=True
  so the import page can surface a migration action.

- Add 'migrate_librenms_id' action in DeviceConflictActionView
  Verifies CF is still an int, requires serial_confirmed or force checkbox,
  calls migrate_legacy_librenms_id() + save().

- Fix collision check to use find_by_librenms_id instead of raw queryset
  The old Device.objects.filter(custom_field_data__librenms_id=int(...))
  only matched the legacy integer format; the new helper matches both
  the JSON dict format and the legacy format.

- Show 'Legacy ID format' badge + 'Migrate ID format' button in template
  Appears in the existing_match_type=='librenms_id' section when
  librenms_id_needs_migration is set. Button is disabled (requires force
  checkbox) when serial not confirmed.

- Add TestLegacyLibreNMSIdMigration test class (6 tests)
- Update TestDeviceConflictActionView mocks for new filter().first() chain

ghost 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: 3

♻️ Duplicate comments (3)
netbox_librenms_plugin/views/imports/actions.py (2)

867-880: ⚠️ Potential issue | 🔴 Critical

Bind conflict mutations to the validated target and enforce object-level authorization.

Line 868 accepts existing_device_id from POST, and Line 874 loads/mutates that record without verifying it matches validation["existing_device"]. A crafted request can target an unrelated device.

🛡️ Proposed fix
         libre_device, validation, selections = self.get_validated_device_with_selections(device_id, request)
         if not libre_device:
             return HttpResponse("LibreNMS device not found", status=404)
+
+        expected_existing = validation.get("existing_device")
+        if not expected_existing or expected_existing.pk != existing_device.pk:
+            return HttpResponse("existing_device_id does not match detected conflict target", status=400)
+        if not request.user.has_perm("dcim.change_device", existing_device):
+            return HttpResponse("Permission denied", status=403)
🤖 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 867 - 880, The
code currently loads an arbitrary Device by POSTed existing_device_id and
mutates it without ensuring it matches the validated target returned by
get_validated_device_with_selections; instead, bind mutations to the validated
object and enforce object-level authorization by either (a) use the validated
device from validation["existing_device"] (or replace existing_device with that
object) rather than reloading from existing_device_id, or (b) if you must
reload, verify int(existing_device_id) == validation["existing_device"].pk and
return 403 on mismatch; then run the view's object-level permission check (e.g.,
call your permission check helper or use request.user.has_perm /
self.check_object_permissions against the resolved Device) before performing any
mutations so only authorized users can modify it.

909-911: ⚠️ Potential issue | 🟠 Major

Escape interpolated values in HTMX HTML responses to prevent XSS.

Lines 909, 950, 972, 1023, 1045, 1060, and 1087 interpolate untrusted values (incoming_serial, device names, librenms_os, hardware, action) directly into HTML response bodies.

🔒 Proposed fix
 from django.http import HttpResponse, JsonResponse
 from django.shortcuts import redirect, render
+from django.utils.html import escape
@@
-                    f"'{id_conflict.name}' (ID: {id_conflict.pk})",
+                    f"'{escape(id_conflict.name)}' (ID: {id_conflict.pk})",
@@
-                        f"Serial conflict: '{incoming_serial}' is already assigned to device "
-                        f"'{conflict_device.name}' (ID: {conflict_device.pk})",
+                        f"Serial conflict: '{escape(incoming_serial)}' is already assigned to device "
+                        f"'{escape(conflict_device.name)}' (ID: {conflict_device.pk})",
@@
-                    return HttpResponse(f"Platform '{librenms_os}' not found in NetBox", status=400)
+                    return HttpResponse(f"Platform '{escape(librenms_os)}' not found in NetBox", status=400)
@@
-                return HttpResponse(f"No matching device type for '{hardware}'", status=400)
+                return HttpResponse(f"No matching device type for '{escape(hardware)}'", status=400)
@@
-            return HttpResponse(f"Unknown action: {action}", status=400)
+            return HttpResponse(f"Unknown action: {escape(action)}", status=400)

Also applies to: 950-952, 972-974, 1023-1025, 1045-1046, 1060-1061, 1087-1087

🤖 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 909 - 911, The
HTMX HTML responses interpolate untrusted values (incoming_serial,
id_conflict.name, librenms_os, hardware, action and device names) directly into
f-strings which can lead to XSS; import and use django.utils.html.escape (or
mark_safe only for trusted content) to escape each of those variables before
embedding them in the response strings in the actions that build
HttpResponse/JsonResponse HTML (the blocks that currently build messages like
"LibreNMS ID conflict: ID {librenms_id} ... '{id_conflict.name}'" and the other
similar f-strings); replace the raw interpolations with escaped versions (e.g.,
escape(incoming_serial), escape(id_conflict.name), escape(librenms_os),
escape(hardware), escape(action)) so all dynamic values are HTML-escaped in the
HTMX responses.
netbox_librenms_plugin/import_utils/device_operations.py (1)

271-272: ⚠️ Potential issue | 🟠 Major

Use one VM-mode source of truth after existing VM detection.

Line 271/358 force result["import_as_vm"] = True, but Lines 441, 565, 612, and 625 still branch/log using the original import_as_vm parameter. That can run device-only validation and VC logic for a VM match.

🔧 Proposed fix
-        # Validate based on import type (Device or VM)
-        if import_as_vm:
+        # Validate based on effective import type (may be forced to VM when existing VM is found)
+        effective_import_as_vm = result["import_as_vm"]
+        if effective_import_as_vm:
@@
-                if serial and serial != "-" and not import_as_vm:
+                if serial and serial != "-" and not effective_import_as_vm:
@@
-                if primary_ip and not import_as_vm:
+                if primary_ip and not effective_import_as_vm:
@@
-        if include_vc_detection and not import_as_vm and api is not None:
+        if include_vc_detection and not effective_import_as_vm and api is not None:
@@
-            if import_as_vm:
+            if effective_import_as_vm:
@@
-            f"Validation for {libre_device.get('hostname')} ({'VM' if import_as_vm else 'Device'}): "
+            f"Validation for {libre_device.get('hostname')} ({'VM' if effective_import_as_vm else 'Device'}): "

Also applies to: 358-359, 441-442, 565-566, 612-625

🤖 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 271 -
272, After forcing result["import_as_vm"] = True when an existing VM is
detected, introduce a single source-of-truth boolean (e.g.,
effective_import_as_vm = result.get("import_as_vm", import_as_vm)) and replace
all subsequent uses of the original import_as_vm parameter (branches,
validations, logging, VC logic) to reference effective_import_as_vm instead;
update any log messages to report effective_import_as_vm so device-only
validation and VC logic follow the corrected VM-mode decision consistently
across the function.
🤖 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/tests/test_import_utils.py`:
- Around line 2216-2223: The test is patching dcim.models.Platform but the code
under test imports and calls netbox_librenms_plugin.utils.find_matching_platform
at runtime; update the test to patch find_matching_platform (e.g.,
patch("netbox_librenms_plugin.utils.find_matching_platform")) instead of
patching Platform so the sync-platform branch uses the mocked result; keep the
other mocks (DeviceConflictActionView.get_validated_device_with_selections,
DeviceConflictActionView.render_device_row, dcim.models.Device) and have the new
find_matching_platform mock return the desired mock_platform value used in the
assertion.

In `@netbox_librenms_plugin/utils.py`:
- Around line 636-649: The function has_nested_name_conflict currently only
checks for MODULE_TOKEN usage and will flag a conflict even when templates use
MODULE_PATH_TOKEN (which yields unique names); update the token checks in
has_nested_name_conflict to detect both tokens: compute uses_name_token =
any(MODULE_TOKEN in t.name for t in templates) and uses_path_token =
any(MODULE_PATH_TOKEN in t.name for t in templates); if neither token is present
return False; if only MODULE_PATH_TOKEN is present return False (no conflict);
only proceed to count ModuleBayModel siblings (using ModuleBayModel, module_bay,
module_id) and return sibling_count > 1 when MODULE_TOKEN is used (i.e.,
uses_name_token is True).

In `@netbox_librenms_plugin/views/imports/actions.py`:
- Around line 906-907: The current collision check uses find_by_librenms_id(...)
and then ignores the match if it equals existing_device, which misses other
duplicates; change the logic to detect any other Device with the same
librenms_id and server key (e.g., query Device for librenms_id and the server
key and exclude existing_device.pk) and treat existence as a collision. Either
adjust find_by_librenms_id to return a queryset or add an exclusion-aware check
(use .exclude(pk=existing_device.pk).exists()) so multiple conflicting devices
are caught.

---

Duplicate comments:
In `@netbox_librenms_plugin/import_utils/device_operations.py`:
- Around line 271-272: After forcing result["import_as_vm"] = True when an
existing VM is detected, introduce a single source-of-truth boolean (e.g.,
effective_import_as_vm = result.get("import_as_vm", import_as_vm)) and replace
all subsequent uses of the original import_as_vm parameter (branches,
validations, logging, VC logic) to reference effective_import_as_vm instead;
update any log messages to report effective_import_as_vm so device-only
validation and VC logic follow the corrected VM-mode decision consistently
across the function.

In `@netbox_librenms_plugin/views/imports/actions.py`:
- Around line 867-880: The code currently loads an arbitrary Device by POSTed
existing_device_id and mutates it without ensuring it matches the validated
target returned by get_validated_device_with_selections; instead, bind mutations
to the validated object and enforce object-level authorization by either (a) use
the validated device from validation["existing_device"] (or replace
existing_device with that object) rather than reloading from existing_device_id,
or (b) if you must reload, verify int(existing_device_id) ==
validation["existing_device"].pk and return 403 on mismatch; then run the view's
object-level permission check (e.g., call your permission check helper or use
request.user.has_perm / self.check_object_permissions against the resolved
Device) before performing any mutations so only authorized users can modify it.
- Around line 909-911: The HTMX HTML responses interpolate untrusted values
(incoming_serial, id_conflict.name, librenms_os, hardware, action and device
names) directly into f-strings which can lead to XSS; import and use
django.utils.html.escape (or mark_safe only for trusted content) to escape each
of those variables before embedding them in the response strings in the actions
that build HttpResponse/JsonResponse HTML (the blocks that currently build
messages like "LibreNMS ID conflict: ID {librenms_id} ... '{id_conflict.name}'"
and the other similar f-strings); replace the raw interpolations with escaped
versions (e.g., escape(incoming_serial), escape(id_conflict.name),
escape(librenms_os), escape(hardware), escape(action)) so all dynamic values are
HTML-escaped in the HTMX responses.

ℹ️ Review info

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

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 4ecce10 and 7d0ec6c.

📒 Files selected for processing (5)
  • netbox_librenms_plugin/import_utils/device_operations.py
  • netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_validation_details.html
  • netbox_librenms_plugin/tests/test_import_utils.py
  • netbox_librenms_plugin/utils.py
  • netbox_librenms_plugin/views/imports/actions.py
📜 Review details
🧰 Additional context used
📓 Path-based instructions (5)
netbox_librenms_plugin/templates/**/*.html

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

netbox_librenms_plugin/templates/**/*.html: HTMX 2.x is the primary async layer. Table row updates should return <tr hx-swap-oob="true">.
Avoid outerHTML swaps in HTMX; use OOB or targeted innerHTML swaps to keep table layout intact.
Modals use Tabler (Bootstrap-like) but without bootstrap.Modal helpers.
Modal buttons should target the htmx-modal-content element. JavaScript in librenms_import.html toggles the wrapper; do not reintroduce data-bs-toggle or duplicate modal IDs.
Keep <select class="device-role-select"> markup stable to preserve JavaScript hook-up with TomSelect decorators.
Styling assumes Tabler defaults. Do not add table-responsive wrappers as they were deliberately removed to prevent dropdown clipping.

Files:

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

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

HTMX fragments should live in templates/netbox_librenms_plugin/htmx/. Keep server responses and HTMX targets in sync.

Files:

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

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

Templates should live in templates/netbox_librenms_plugin/. Reusable template includes should be placed under inc/ subdirectory.

Files:

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

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

**/*.py: Reuse the librenms_api.py LibreNMS client instead of making new requests calls; it handles multi-server configs via LibreNMSSettings model and the servers plugin config, plus caching
Always call LibreNMSAPI.get_librenms_id to retrieve the device/VM LibreNMS mapping via the librenms_id custom field instead of touching the field directly
Matching must be exact-only for site, platform, device type, and role; do not add fuzzy matching; use the functions find_matching_site, match_librenms_hardware_to_device_type, and find_matching_platform from utils.py

Files:

  • netbox_librenms_plugin/utils.py
  • netbox_librenms_plugin/views/imports/actions.py
  • netbox_librenms_plugin/import_utils/device_operations.py
  • netbox_librenms_plugin/tests/test_import_utils.py
**/views/imports/**

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

**/views/imports/**: Use Job UUID (job.job_id) for RQ API endpoints: /api/core/background-tasks/{uuid}/
Use Job PK (job.pk) for database endpoints and result loading
Check rq_job.is_stopped or rq_job.is_failed flags in Redis for cancellation detection, not database status
Call /api/core/background-tasks/{uuid}/stop/ to stop RQ job in the job cancellation flow
Call plugin's sync endpoint /api/plugins/librenms_plugin/jobs/{pk}/sync-status/ to update database after stopping RQ job
Poll /api/core/background-tasks/{uuid}/ for real-time RQ status instead of polling the database
api/views.py::sync_job_status() must sync database Job status with RQ job status because NetBox worker doesn't always update DB when jobs stop before processing starts

Files:

  • netbox_librenms_plugin/views/imports/actions.py
🧠 Learnings (18)
📚 Learning: 2026-02-15T15:39:11.578Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/frontend.instructions.md:0-0
Timestamp: 2026-02-15T15:39:11.578Z
Learning: Applies to netbox_librenms_plugin/templates/**/*.html : Modal buttons should target the `htmx-modal-content` element. 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/htmx/device_validation_details.html
📚 Learning: 2026-02-15T15:39:11.578Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/frontend.instructions.md:0-0
Timestamp: 2026-02-15T15:39:11.578Z
Learning: Applies to netbox_librenms_plugin/templates/**/*.html : Avoid `outerHTML` swaps in HTMX; use OOB 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/views/imports/actions.py
📚 Learning: 2026-02-15T15:39:11.578Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/frontend.instructions.md:0-0
Timestamp: 2026-02-15T15:39:11.578Z
Learning: Applies to netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/**/*.html : HTMX fragments should live in `templates/netbox_librenms_plugin/htmx/`. Keep server responses and HTMX targets in sync.

Applied to files:

  • netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_validation_details.html
  • netbox_librenms_plugin/views/imports/actions.py
📚 Learning: 2026-02-15T15:39:11.578Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/frontend.instructions.md:0-0
Timestamp: 2026-02-15T15:39:11.578Z
Learning: Applies to netbox_librenms_plugin/templates/**/*.html : HTMX 2.x is the primary async layer. Table row updates should return `<tr hx-swap-oob="true">`.

Applied to files:

  • netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_validation_details.html
  • netbox_librenms_plugin/views/imports/actions.py
📚 Learning: 2026-02-15T15:38:54.189Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-02-15T15:38:54.189Z
Learning: Applies to views/imports/**/*.py : REST endpoints for imports live in `views/imports/actions.py` with the list view in `views/imports/list.py`, surface via `urls.py`, and emit HTMX fragments in `templates/netbox_librenms_plugin/htmx/`; keep server responses and HTMX targets in sync

Applied to files:

  • netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_validation_details.html
  • netbox_librenms_plugin/views/imports/actions.py
  • netbox_librenms_plugin/import_utils/device_operations.py
  • netbox_librenms_plugin/tests/test_import_utils.py
📚 Learning: 2026-02-15T15:39:11.578Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/frontend.instructions.md:0-0
Timestamp: 2026-02-15T15:39:11.578Z
Learning: Applies to netbox_librenms_plugin/templates/**/*.html : Keep `<select class="device-role-select">` markup stable to preserve JavaScript hook-up with TomSelect decorators.

Applied to files:

  • netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_validation_details.html
📚 Learning: 2026-02-15T15:39:11.578Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/frontend.instructions.md:0-0
Timestamp: 2026-02-15T15:39:11.578Z
Learning: Applies to netbox_librenms_plugin/templates/**/*.html : Modals use Tabler (Bootstrap-like) but without `bootstrap.Modal` helpers.

Applied to files:

  • netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_validation_details.html
📚 Learning: 2026-02-15T15:39:11.578Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/frontend.instructions.md:0-0
Timestamp: 2026-02-15T15:39:11.578Z
Learning: Applies to netbox_librenms_plugin/templates/**/*.html : Styling assumes Tabler defaults. Do not add `table-responsive` wrappers as they were deliberately removed to prevent dropdown clipping.

Applied to files:

  • netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_validation_details.html
📚 Learning: 2026-02-15T15:38:54.189Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-02-15T15:38:54.189Z
Learning: Applies to **/*.py : Matching must be exact-only for site, platform, device type, and role; do not add fuzzy matching; use the functions `find_matching_site`, `match_librenms_hardware_to_device_type`, and `find_matching_platform` from `utils.py`

Applied to files:

  • netbox_librenms_plugin/utils.py
  • netbox_librenms_plugin/views/imports/actions.py
  • netbox_librenms_plugin/tests/test_import_utils.py
📚 Learning: 2026-02-15T15:39:20.744Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/testing.instructions.md:0-0
Timestamp: 2026-02-15T15:39:20.744Z
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/views/imports/actions.py
  • netbox_librenms_plugin/import_utils/device_operations.py
  • netbox_librenms_plugin/tests/test_import_utils.py
📚 Learning: 2026-02-15T15:38:54.189Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-02-15T15:38:54.189Z
Learning: Applies to {navigation.py,urls.py,api/**} : Respect NetBox plugin APIs in `navigation.py`, `urls.py`, and `api/` directories

Applied to files:

  • netbox_librenms_plugin/views/imports/actions.py
  • netbox_librenms_plugin/import_utils/device_operations.py
📚 Learning: 2026-02-15T15:39:11.578Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/frontend.instructions.md:0-0
Timestamp: 2026-02-15T15:39:11.578Z
Learning: Applies to netbox_librenms_plugin/templates/netbox_librenms_plugin/*sync*.html : Sync pages should extend `librenms_sync_base.html`.

Applied to files:

  • netbox_librenms_plugin/views/imports/actions.py
📚 Learning: 2026-02-15T15:38:54.189Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-02-15T15:38:54.189Z
Learning: Applies to **/*.py : Always call `LibreNMSAPI.get_librenms_id` to retrieve the device/VM LibreNMS mapping via the `librenms_id` custom field instead of touching the field directly

Applied to files:

  • netbox_librenms_plugin/views/imports/actions.py
📚 Learning: 2026-02-15T15:38:54.189Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-02-15T15:38:54.189Z
Learning: Follow sync pipeline flow: fetch LibreNMS data via `librenms_api.py`, cache it using `CacheMixin`, build comparison tables in `tables/`, and render HTMX fragments in `templates/netbox_librenms_plugin/htmx/`

Applied to files:

  • netbox_librenms_plugin/import_utils/device_operations.py
📚 Learning: 2026-02-15T15:39:20.744Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/testing.instructions.md:0-0
Timestamp: 2026-02-15T15:39:20.744Z
Learning: Applies to tests/**/*.py : Test coverage mapping: `librenms_api.py` → `test_librenms_api.py`, `import_utils.py`/`import_validation_helpers.py`/`utils.py` → `test_import_utils.py`/`test_import_validation_helpers.py`/`test_utils.py`, `jobs.py`/`views/imports/list.py` → `test_background_jobs.py`

Applied to files:

  • netbox_librenms_plugin/tests/test_import_utils.py
📚 Learning: 2026-02-15T15:39:20.744Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/testing.instructions.md:0-0
Timestamp: 2026-02-15T15:39:20.744Z
Learning: Applies to tests/test_import_utils.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-02-15T15:39:20.744Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/testing.instructions.md:0-0
Timestamp: 2026-02-15T15:39:20.744Z
Learning: Applies to tests/test_background_jobs.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_import_utils.py
📚 Learning: 2026-02-15T15:39:20.744Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/testing.instructions.md:0-0
Timestamp: 2026-02-15T15:39:20.744Z
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
🧬 Code graph analysis (3)
netbox_librenms_plugin/utils.py (1)
netbox_librenms_plugin/models.py (2)
  • DeviceTypeMapping (84-113)
  • NormalizationRule (205-279)
netbox_librenms_plugin/import_utils/device_operations.py (6)
netbox_librenms_plugin/librenms_api.py (3)
  • LibreNMSAPI (15-1084)
  • get_inventory_filtered (771-848)
  • get_device_info (314-337)
netbox_librenms_plugin/utils.py (2)
  • find_matching_platform (404-434)
  • find_by_librenms_id (67-86)
netbox_librenms_plugin/import_utils/cache.py (1)
  • get_import_device_cache_key (139-158)
netbox_librenms_plugin/import_utils/virtual_chassis.py (2)
  • empty_virtual_chassis_data (15-23)
  • get_virtual_chassis_data (60-80)
netbox_librenms_plugin/views/imports/actions.py (2)
  • get (707-727)
  • get (733-757)
netbox_librenms_plugin/views/base/librenms_sync_view.py (1)
  • get (25-46)
netbox_librenms_plugin/tests/test_import_utils.py (2)
netbox_librenms_plugin/import_utils/device_operations.py (1)
  • validate_device_for_import (117-635)
netbox_librenms_plugin/utils.py (1)
  • migrate_legacy_librenms_id (89-119)
🔇 Additional comments (5)
netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_validation_details.html (5)

23-23: Modal close button uses Bootstrap dismissal instead of HTMX-targeted close.

This close button uses data-bs-dismiss="modal" which conflicts with the HTMX/Tabler modal pattern. It should target #htmx-modal-content per the project guidelines.


40-79: LGTM!

The LibreNMS Status card is clean and well-structured with proper status badge coloring and consistent table formatting.


86-121: LGTM!

The Device Information table with per-field actions is well-implemented. The HTMX forms correctly use hx-swap="none" and include CSRF tokens. The conditional inclusion of hx-include for name-related toggles aligns with action-specific requirements.


359-406: LGTM!

The validation state banners and migration flow are well-implemented with proper conditional rendering and inline form controls. The force-checkbox pattern with JavaScript-enabled submit buttons provides appropriate guardrails for risky operations.


588-590: Footer close button also uses Bootstrap dismissal.

Same issue as the header close button—this should target #htmx-modal-content rather than relying on data-bs-dismiss="modal".

Comment thread netbox_librenms_plugin/tests/test_import_utils.py
Comment on lines +636 to +649
uses_module_token = any(MODULE_TOKEN in t.name for t in templates)
if not uses_module_token:
return False # Template doesn't use {module}

# Count how many unique interface names this template would produce across siblings
# If all siblings resolve to the same name, there's a conflict
from dcim.models import ModuleBay as ModuleBayModel

sibling_count = ModuleBayModel.objects.filter(
device=module_bay.device,
module_id=module_bay.module_id,
).count()

return sibling_count > 1

ghost Mar 1, 2026

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

has_nested_name_conflict() can falsely block valid nested installs when {module_path} is present.

Line 636 only checks for {module} token usage. If templates include {module_path}, names can remain unique, but this branch still flags a conflict based on sibling count.

🔧 Proposed fix
 def has_nested_name_conflict(module_type, module_bay):
@@
     uses_module_token = any(MODULE_TOKEN in t.name for t in templates)
     if not uses_module_token:
         return False  # Template doesn't use {module}
+    # If {module_path} is used, nested sibling names can still resolve uniquely.
+    if module_type_uses_module_path(module_type):
+        return False

     # Count how many unique interface names this template would produce across siblings
🤖 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 636 - 649, The function
has_nested_name_conflict currently only checks for MODULE_TOKEN usage and will
flag a conflict even when templates use MODULE_PATH_TOKEN (which yields unique
names); update the token checks in has_nested_name_conflict to detect both
tokens: compute uses_name_token = any(MODULE_TOKEN in t.name for t in templates)
and uses_path_token = any(MODULE_PATH_TOKEN in t.name for t in templates); if
neither token is present return False; if only MODULE_PATH_TOKEN is present
return False (no conflict); only proceed to count ModuleBayModel siblings (using
ModuleBayModel, module_bay, module_id) and return sibling_count > 1 when
MODULE_TOKEN is used (i.e., uses_name_token is True).

Comment thread netbox_librenms_plugin/views/imports/actions.py Outdated
Marcin Zieba added 4 commits March 2, 2026 00:01
… accessibility

- device_operations.py: preserve device_type schema dict instead of replacing
  with raw dt_match; 'found' key is now always set regardless of match result
- device_operations.py: use api.server_key instead of server_key or 'default'
  to consistently use the resolved server key after LibreNMSAPI instantiation
- device_operations.py: move Q import to module level; remove inline
  'from dcim.models import Rack' so test mock patching works correctly
- bulk_import.py: fix 7 early-return sites to return ([], False) when
  return_cache_status=True, keeping the return shape consistent for callers
- device_status.py: add 'Legacy ID' warning badge when librenms_id_needs_migration
  is set on an existing device matched by librenms_id
- device_status.py: add aria-label to icon-only buttons for accessibility
- device_validation_details.html: add rel='noopener noreferrer' to all
  target='_blank' anchors (8 links)
- test_import_utils.py: add DeviceType to SERIAL_PATCHES so suggestions path
  is mocked; fix _create_view to use real instantiation; fix _setup_no_existing
  to use named variables instead of fragile negative indexing; remove dead
  mock_rack.objects.filter.return_value = [] lines (inline import bypass meant
  these were never effective); fix device_type assertions to use 'found' key
- import_utils/cache.py: guard datetime.fromisoformat with try/except fallback;
  replace hash() with deterministic sha256 for cross-process stable cache keys
- import_utils/filters.py: replace hash() with deterministic sha256 for
  librenms_devices_import cache key; add hashlib/json imports
- import_utils/permissions.py: remove unused logger and logging import
- import_utils/virtual_chassis.py: fix blank serial skip logic - only skip
  member when serial is non-empty AND matches master serial; same fix in
  expected_members count
- device_validation_details.html: add aria-label to 6 icon-only sync buttons
  for screen-reader accessibility
- test_import_utils.py: replace fragile mocks[-2] indexing with named unpacking
- views/imports/actions.py: import escape(); HTML-escape untrusted values
  (id_conflict.name, librenms_os, hardware, action) in HttpResponse f-strings
- views/sync/interfaces.py: guard handle_mac_address with is_device_interface
  to avoid setting MAC on VMInterface objects
- import_utils/device_operations.py: reassign import_as_vm from result dict
  after existing-object detection so downstream branches use effective VM mode
- __init__.py: move _executed = True to inside try block after successful
  custom field creation, so failures allow retry on next post_migrate
- tests/test_init.py: assert _executed is False after exception in
  test_exception_does_not_propagate to verify retry behaviour
- views/base/librenms_sync_view.py: remove redundant found_in_librenms = True
  inside mismatched_device block (already set on line 184 after API call)
@marcinpsk

ghost commented Mar 2, 2026

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

ghost commented Mar 2, 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.

ghost 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: 5

♻️ Duplicate comments (2)
netbox_librenms_plugin/import_utils/device_operations.py (1)

268-273: ⚠️ Potential issue | 🟠 Major

Use a single VM-mode source of truth after existing-VM detection.

After VM mode is forced on Line 272 / Line 359, later branches still read the original import_as_vm parameter (Line 399, Line 423, Line 442, Line 561, Line 608, Line 621). That can run device-only validation/VC paths for VM-linked records.

🔧 Proposed fix
@@
-    try:
+    try:
+        effective_import_as_vm = import_as_vm
@@
-            result["import_as_vm"] = True  # Force VM mode since VM exists
+            result["import_as_vm"] = True  # Force VM mode since VM exists
+            effective_import_as_vm = True
@@
-                result["import_as_vm"] = True  # Force VM mode since VM exists
+                result["import_as_vm"] = True  # Force VM mode since VM exists
+                effective_import_as_vm = True
@@
-                if serial and serial != "-" and not import_as_vm:
+                if serial and serial != "-" and not effective_import_as_vm:
@@
-                if primary_ip and not import_as_vm:
+                if primary_ip and not effective_import_as_vm:
@@
-        if import_as_vm:
+        if effective_import_as_vm:
@@
-        if include_vc_detection and not import_as_vm and api is not None:
+        if include_vc_detection and not effective_import_as_vm and api is not None:
@@
-            if import_as_vm:
+            if effective_import_as_vm:
@@
-            f"Validation for {libre_device.get('hostname')} ({'VM' if import_as_vm else 'Device'}): "
+            f"Validation for {libre_device.get('hostname')} ({'VM' if effective_import_as_vm else 'Device'}): "

Also applies to: 359-360, 399-399, 423-423, 442-443, 561-562, 608-609, 621-621

🤖 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 268 -
273, After detecting an existing VM (existing_vm) you force VM mode by setting
result["import_as_vm"]=True but later branches still read the original
import_as_vm parameter; update the code so all subsequent checks use a single
VM-mode source of truth (e.g., replace references to the original import_as_vm
with result["import_as_vm"] or create a local vm_mode =
result.get("import_as_vm", import_as_vm) and use vm_mode everywhere) including
in the validation/VC paths that currently read import_as_vm (the branches around
the usages indicated by existing_vm, and the later checks at the spots
referenced in the review).
netbox_librenms_plugin/tests/test_import_utils.py (1)

2198-2206: ⚠️ Potential issue | 🟠 Major

Patch the runtime helper in test_sync_platform_action.

Line 2202 patches dcim.models.Platform, but the sync-platform flow resolves platform through the matching helper. The test currently doesn’t control the dependency that actually executes.

🔧 Proposed fix
         with (
             patch.object(DeviceConflictActionView, "get_validated_device_with_selections") as mock_validate,
             patch.object(DeviceConflictActionView, "render_device_row") as mock_render,
             patch("dcim.models.Device") as mock_device_cls,
-            patch("dcim.models.Platform") as mock_platform_cls,
+            patch("netbox_librenms_plugin.utils.find_matching_platform") as mock_find_platform,
         ):
             mock_device_cls.objects.get.return_value = existing_device
-            mock_platform_cls.objects.get.return_value = mock_platform
+            mock_find_platform.return_value = {"found": True, "platform": mock_platform, "match_type": "exact"}
             mock_validate.return_value = (libre_device, validation, selections)
             mock_render.return_value = MagicMock()

Based on learnings: Patch deferred/inline imports at their source module (e.g., netbox_librenms_plugin.import_utils.process_device_filters), not the consuming module.

🤖 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/bulk_import.py`:
- Around line 321-329: libre_device may lack _server_key so server_key can be
None and cause find_by_librenms_id to miss JSON-mapped records; ensure
server_key defaults to a non-None value before the librenms_id check by
replacing server_key = libre_device.get("_server_key") with something like
server_key = libre_device.get("_server_key") or "" (or another appropriate
fallback from your import context) so the call to find_by_librenms_id(Device,
int(librenms_id), server_key) always receives a valid string key.
- Around line 296-306: The code is forcing validation["can_import"]=True and
computing is_ready using device fields even for VMs; update the logic to mirror
validate_device_for_import: do not unconditionally set can_import True, instead
compute can_import/is_ready based on validation.get("import_as_vm")—if True
require site and cluster (and any other VM-specific checks from
validate_device_for_import), otherwise require site, device_type and
device_role; you can either call validate_device_for_import to derive these
flags or duplicate its exact checks using the validation keys ("import_as_vm",
"site", "cluster", "device_type", "device_role") so VM rows are only marked
ready when a cluster is found.

In `@netbox_librenms_plugin/import_utils/device_operations.py`:
- Around line 276-277: Replace direct reads of
existing_vm.custom_field_data["librenms_id"] with the centralized accessor
LibreNMSAPI.get_librenms_id(...) so validation follows the project's ID-access
contract; specifically, in device_operations.py where you set
result["librenms_id_needs_migration"] (and the similar checks around the related
block at the other occurrence noted), call
LibreNMSAPI.get_librenms_id(existing_vm) (or the appropriate instance/context)
and base the int-type check on its return value instead of reading
custom_field_data directly.

In
`@netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_validation_details.html`:
- Around line 111-113: Several icon-only <button> elements (e.g., the button
with class "btn btn-sm btn-outline-primary py-0 px-1" and title "Sync name to {{
validation.suggested_name }}") rely solely on title attributes which are not
reliably announced by screen readers; add aria-label attributes that match the
title text for each icon-only button in this template (including the other
instances with similar classes/icons), e.g., aria-label="Sync name to {{
validation.suggested_name }}", ensuring every icon-only button (sync/action
buttons with only an <i class="mdi ..."> inside) has a corresponding aria-label
that mirrors its title.
- Around line 45-76: The template device_validation_details.html repeats inline
style "padding-left: 0.75rem;" across several <th> (and some <td>) elements;
replace those inline styles with a reusable CSS class (e.g., add
class="tbl-cell-pl" or use an existing utility like "pl-3") on the <th> and <td>
elements in the table markup and add the CSS rule (e.g., .tbl-cell-pl {
padding-left: 0.75rem; }) to the plugin's stylesheet so all occurrences
(including the Status/Hostname/ID/IP/Location rows) use the class instead of
inline style.

---

Duplicate comments:
In `@netbox_librenms_plugin/import_utils/device_operations.py`:
- Around line 268-273: After detecting an existing VM (existing_vm) you force VM
mode by setting result["import_as_vm"]=True but later branches still read the
original import_as_vm parameter; update the code so all subsequent checks use a
single VM-mode source of truth (e.g., replace references to the original
import_as_vm with result["import_as_vm"] or create a local vm_mode =
result.get("import_as_vm", import_as_vm) and use vm_mode everywhere) including
in the validation/VC paths that currently read import_as_vm (the branches around
the usages indicated by existing_vm, and the later checks at the spots
referenced in the review).

ℹ️ Review info

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

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 7d0ec6c and 46d3775.

📒 Files selected for processing (6)
  • netbox_librenms_plugin/import_utils/bulk_import.py
  • netbox_librenms_plugin/import_utils/device_operations.py
  • netbox_librenms_plugin/tables/device_status.py
  • netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_validation_details.html
  • netbox_librenms_plugin/tests/test_import_utils.py
  • netbox_librenms_plugin/tests/test_init.py
📜 Review details
🧰 Additional context used
📓 Path-based instructions (4)
**/*.py

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

**/*.py: Reuse the librenms_api.py LibreNMS client instead of making new requests calls; it handles multi-server configs via LibreNMSSettings model and the servers plugin config, plus caching
Always call LibreNMSAPI.get_librenms_id to retrieve the device/VM LibreNMS mapping via the librenms_id custom field instead of touching the field directly
Matching must be exact-only for site, platform, device type, and role; do not add fuzzy matching; use the functions find_matching_site, match_librenms_hardware_to_device_type, and find_matching_platform from utils.py

Files:

  • netbox_librenms_plugin/tables/device_status.py
  • netbox_librenms_plugin/tests/test_init.py
  • netbox_librenms_plugin/tests/test_import_utils.py
  • netbox_librenms_plugin/import_utils/device_operations.py
  • netbox_librenms_plugin/import_utils/bulk_import.py
netbox_librenms_plugin/templates/**/*.html

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

netbox_librenms_plugin/templates/**/*.html: HTMX 2.x is the primary async layer. Table row updates should return <tr hx-swap-oob="true">.
Avoid outerHTML swaps in HTMX; use OOB or targeted innerHTML swaps to keep table layout intact.
Modals use Tabler (Bootstrap-like) but without bootstrap.Modal helpers.
Modal buttons should target the htmx-modal-content element. JavaScript in librenms_import.html toggles the wrapper; do not reintroduce data-bs-toggle or duplicate modal IDs.
Keep <select class="device-role-select"> markup stable to preserve JavaScript hook-up with TomSelect decorators.
Styling assumes Tabler defaults. Do not add table-responsive wrappers as they were deliberately removed to prevent dropdown clipping.

Files:

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

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

HTMX fragments should live in templates/netbox_librenms_plugin/htmx/. Keep server responses and HTMX targets in sync.

Files:

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

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

Templates should live in templates/netbox_librenms_plugin/. Reusable template includes should be placed under inc/ subdirectory.

Files:

  • netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_validation_details.html
🧠 Learnings (17)
📓 Common learnings
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-02-15T15:38:54.189Z
Learning: Applies to views/imports/**/*.py : REST endpoints for imports live in `views/imports/actions.py` with the list view in `views/imports/list.py`, surface via `urls.py`, and emit HTMX fragments in `templates/netbox_librenms_plugin/htmx/`; keep server responses and HTMX targets in sync
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-02-15T15:38:54.189Z
Learning: Follow sync pipeline flow: fetch LibreNMS data via `librenms_api.py`, cache it using `CacheMixin`, build comparison tables in `tables/`, and render HTMX fragments in `templates/netbox_librenms_plugin/htmx/`
📚 Learning: 2026-02-15T15:39:11.578Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/frontend.instructions.md:0-0
Timestamp: 2026-02-15T15:39:11.578Z
Learning: Applies to netbox_librenms_plugin/templates/**/*.html : Modal buttons should target the `htmx-modal-content` element. JavaScript in `librenms_import.html` toggles the wrapper; do not reintroduce `data-bs-toggle` or duplicate modal IDs.

Applied to files:

  • netbox_librenms_plugin/tables/device_status.py
  • netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_validation_details.html
📚 Learning: 2026-02-15T15:39:11.578Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/frontend.instructions.md:0-0
Timestamp: 2026-02-15T15:39:11.578Z
Learning: Applies to netbox_librenms_plugin/templates/**/*.html : Avoid `outerHTML` swaps in HTMX; use OOB or targeted `innerHTML` swaps to keep table layout intact.

Applied to files:

  • netbox_librenms_plugin/tables/device_status.py
  • netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_validation_details.html
📚 Learning: 2026-02-15T15:39:11.578Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/frontend.instructions.md:0-0
Timestamp: 2026-02-15T15:39:11.578Z
Learning: Applies to netbox_librenms_plugin/templates/**/*.html : Keep `<select class="device-role-select">` markup stable to preserve JavaScript hook-up with TomSelect decorators.

Applied to files:

  • netbox_librenms_plugin/tables/device_status.py
  • netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_validation_details.html
📚 Learning: 2026-02-15T15:39:11.578Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/frontend.instructions.md:0-0
Timestamp: 2026-02-15T15:39:11.578Z
Learning: Applies to netbox_librenms_plugin/templates/**/*.html : HTMX 2.x is the primary async layer. Table row updates should return `<tr hx-swap-oob="true">`.

Applied to files:

  • netbox_librenms_plugin/tables/device_status.py
  • netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_validation_details.html
📚 Learning: 2026-02-15T15:39:11.578Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/frontend.instructions.md:0-0
Timestamp: 2026-02-15T15:39:11.578Z
Learning: Applies to netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/**/*.html : HTMX fragments should live in `templates/netbox_librenms_plugin/htmx/`. Keep server responses and HTMX targets in sync.

Applied to files:

  • netbox_librenms_plugin/tables/device_status.py
  • netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_validation_details.html
📚 Learning: 2026-02-15T15:38:54.189Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-02-15T15:38:54.189Z
Learning: Applies to views/imports/**/*.py : REST endpoints for imports live in `views/imports/actions.py` with the list view in `views/imports/list.py`, surface via `urls.py`, and emit HTMX fragments in `templates/netbox_librenms_plugin/htmx/`; keep server responses and HTMX targets in sync

Applied to files:

  • netbox_librenms_plugin/tables/device_status.py
  • netbox_librenms_plugin/tests/test_import_utils.py
  • netbox_librenms_plugin/import_utils/device_operations.py
  • netbox_librenms_plugin/import_utils/bulk_import.py
📚 Learning: 2026-02-15T15:39:20.744Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/testing.instructions.md:0-0
Timestamp: 2026-02-15T15:39:20.744Z
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_init.py
  • netbox_librenms_plugin/tests/test_import_utils.py
  • netbox_librenms_plugin/import_utils/device_operations.py
  • netbox_librenms_plugin/import_utils/bulk_import.py
📚 Learning: 2026-02-15T15:39:20.744Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/testing.instructions.md:0-0
Timestamp: 2026-02-15T15:39:20.744Z
Learning: Applies to tests/**/*.py : Test coverage mapping: `librenms_api.py` → `test_librenms_api.py`, `import_utils.py`/`import_validation_helpers.py`/`utils.py` → `test_import_utils.py`/`test_import_validation_helpers.py`/`test_utils.py`, `jobs.py`/`views/imports/list.py` → `test_background_jobs.py`

Applied to files:

  • netbox_librenms_plugin/tests/test_init.py
  • netbox_librenms_plugin/tests/test_import_utils.py
📚 Learning: 2026-02-15T15:39:20.744Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/testing.instructions.md:0-0
Timestamp: 2026-02-15T15:39:20.744Z
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_init.py
  • netbox_librenms_plugin/tests/test_import_utils.py
📚 Learning: 2026-02-15T15:39:20.744Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/testing.instructions.md:0-0
Timestamp: 2026-02-15T15:39:20.744Z
Learning: Applies to tests/test_background_jobs.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_import_utils.py
📚 Learning: 2026-02-15T15:39:20.744Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/testing.instructions.md:0-0
Timestamp: 2026-02-15T15:39:20.744Z
Learning: Applies to tests/test_import_utils.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
  • netbox_librenms_plugin/import_utils/bulk_import.py
📚 Learning: 2026-02-15T15:38:54.189Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-02-15T15:38:54.189Z
Learning: Applies to **/*.py : Matching must be exact-only for site, platform, device type, and role; do not add fuzzy matching; use the functions `find_matching_site`, `match_librenms_hardware_to_device_type`, and `find_matching_platform` from `utils.py`

Applied to files:

  • netbox_librenms_plugin/tests/test_import_utils.py
📚 Learning: 2026-02-15T15:38:54.189Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-02-15T15:38:54.189Z
Learning: Follow sync pipeline flow: fetch LibreNMS data via `librenms_api.py`, cache it using `CacheMixin`, build comparison tables in `tables/`, and render HTMX fragments in `templates/netbox_librenms_plugin/htmx/`

Applied to files:

  • netbox_librenms_plugin/import_utils/bulk_import.py
📚 Learning: 2026-02-15T15:39:11.578Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/frontend.instructions.md:0-0
Timestamp: 2026-02-15T15:39:11.578Z
Learning: Applies to netbox_librenms_plugin/templates/**/*.html : Modals use Tabler (Bootstrap-like) but without `bootstrap.Modal` helpers.

Applied to files:

  • netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_validation_details.html
📚 Learning: 2026-02-15T15:39:11.578Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/frontend.instructions.md:0-0
Timestamp: 2026-02-15T15:39:11.578Z
Learning: Applies to netbox_librenms_plugin/templates/**/*.html : Styling assumes Tabler defaults. Do not add `table-responsive` wrappers as they were deliberately removed to prevent dropdown clipping.

Applied to files:

  • netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_validation_details.html
📚 Learning: 2026-02-15T15:39:11.578Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/frontend.instructions.md:0-0
Timestamp: 2026-02-15T15:39:11.578Z
Learning: Applies to netbox_librenms_plugin/templates/netbox_librenms_plugin/*sync*.html : Sync pages should extend `librenms_sync_base.html`.

Applied to files:

  • netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_validation_details.html
🧬 Code graph analysis (4)
netbox_librenms_plugin/tables/device_status.py (1)
netbox_librenms_plugin/views/imports/actions.py (2)
  • get (707-727)
  • get (733-757)
netbox_librenms_plugin/tests/test_init.py (1)
netbox_librenms_plugin/__init__.py (1)
  • _ensure_librenms_id_custom_field (71-134)
netbox_librenms_plugin/tests/test_import_utils.py (1)
netbox_librenms_plugin/utils.py (1)
  • migrate_legacy_librenms_id (89-119)
netbox_librenms_plugin/import_utils/bulk_import.py (7)
netbox_librenms_plugin/librenms_api.py (2)
  • LibreNMSAPI (15-1084)
  • get_device_info (314-337)
netbox_librenms_plugin/utils.py (1)
  • find_by_librenms_id (67-86)
netbox_librenms_plugin/import_utils/cache.py (3)
  • get_cache_metadata_key (10-24)
  • get_import_device_cache_key (139-158)
  • get_validated_device_cache_key (112-136)
netbox_librenms_plugin/import_utils/device_operations.py (2)
  • import_single_device (634-853)
  • validate_device_for_import (118-631)
netbox_librenms_plugin/import_utils/filters.py (1)
  • get_librenms_devices_for_import (43-213)
netbox_librenms_plugin/import_utils/permissions.py (1)
  • require_permissions (31-48)
netbox_librenms_plugin/import_utils/virtual_chassis.py (3)
  • create_virtual_chassis_with_members (310-440)
  • empty_virtual_chassis_data (15-23)
  • prefetch_vc_data_for_devices (83-117)
🔇 Additional comments (6)
netbox_librenms_plugin/tests/test_init.py (1)

63-64: Good log verification depth.

This now verifies the logger instance actually emitted .info(), not just that getLogger() was called.

netbox_librenms_plugin/tables/device_status.py (1)

491-503: Accessible icon-only button handling looks good.

The conditional aria-label injection for empty-label buttons improves screen-reader coverage while preserving existing title/HTMX behavior.

netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_validation_details.html (4)

23-23: Modal close buttons still use data-bs-dismiss instead of targeting #htmx-modal-content.

This was flagged in a previous review. The close buttons at lines 23 and 588 use Bootstrap-style dismissal which conflicts with the HTMX/Tabler modal pattern. As per coding guidelines, modal buttons should target the htmx-modal-content element.

Also applies to: 588-588


100-102: Good use of rel="noopener noreferrer" on external links.

All external links (target="_blank") correctly include security attributes to prevent tabnabbing attacks.

Also applies to: 422-423, 482-484, 519-521, 529-530, 571-578, 581-583


104-114: HTMX form patterns are appropriate.

The hx-swap="none" usage is correct for these action forms since they trigger server-side operations without needing to swap content. The hx-include directive properly references external toggle inputs.

Also applies to: 151-162, 166-175, 206-215, 260-269, 284-293


6-21: Header icon state logic is clear and comprehensive.

The conditional icon rendering covers all relevant validation states (device type mismatch, librenms_id match, existing device warnings, ready, can_import, blocked) with appropriate visual indicators.

Comment thread netbox_librenms_plugin/import_utils/bulk_import.py Outdated
Comment thread netbox_librenms_plugin/import_utils/bulk_import.py Outdated
Comment on lines +276 to +277
if isinstance(existing_vm.custom_field_data.get("librenms_id"), int):
result["librenms_id_needs_migration"] = True

ghost Mar 2, 2026

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

Stop reading custom_field_data["librenms_id"] directly in validation.

These checks bypass the project’s central ID-access contract and can drift from server-key handling rules. Route reads through the standard helper/API method for librenms_id access.

As per coding guidelines: Always call LibreNMSAPI.get_librenms_id to retrieve the device/VM LibreNMS mapping via the librenms_id custom field instead of touching the field directly.

Also applies to: 303-304

🤖 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 276 -
277, Replace direct reads of existing_vm.custom_field_data["librenms_id"] with
the centralized accessor LibreNMSAPI.get_librenms_id(...) so validation follows
the project's ID-access contract; specifically, in device_operations.py where
you set result["librenms_id_needs_migration"] (and the similar checks around the
related block at the other occurrence noted), call
LibreNMSAPI.get_librenms_id(existing_vm) (or the appropriate instance/context)
and base the int-type check on its return value instead of reading
custom_field_data directly.

ghost Mar 2, 2026

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

direct read is required in this one place - we are detecting if we have legacy key saved and need to flag it for migration - or not. alternative would be to extend helper to flag records in this way (return as well librenms_id_needs_migration) - which seems like not really needed.

ghost Mar 2, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@marcinpsk, that's a fair point! You're right that detecting the legacy format requires inspecting the raw field type. The helper function is designed to normalize access and hide that distinction, which is exactly what you don't want here.

This is a legitimate exception to the guideline—you need to inspect the storage format itself to determine if migration is needed, not just retrieve the value. The existing guideline still applies for normal data access throughout the codebase.

To make this intent clear to future maintainers, I'd suggest adding a brief comment above the direct access explaining why it's necessary:

# Direct access needed to detect legacy integer format for migration prompt
if isinstance(existing_vm.custom_field_data.get("librenms_id"), int):
    result["librenms_id_needs_migration"] = True

This documents the exception while keeping the code straightforward.


🧠 Learnings used
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-02-15T15:38:54.189Z
Learning: Applies to **/*.py : Always call `LibreNMSAPI.get_librenms_id` to retrieve the device/VM LibreNMS mapping via the `librenms_id` custom field instead of touching the field directly

Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/testing.instructions.md:0-0
Timestamp: 2026-02-15T15:39:20.744Z
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

Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-02-15T15:38:54.189Z
Learning: Applies to views/imports/**/*.py : REST endpoints for imports live in `views/imports/actions.py` with the list view in `views/imports/list.py`, surface via `urls.py`, and emit HTMX fragments in `templates/netbox_librenms_plugin/htmx/`; keep server responses and HTMX targets in sync

Comment on lines +45 to +76
<table class="table table-sm mb-0">
<tbody>
<tr>
<th style="padding-left: 0.75rem;">Status</th>
<td>
{% if libre_device.status == 1 %}
<span class="badge bg-success text-white"><i class="mdi mdi-check"></i> Up</span>
{% elif libre_device.status == 0 %}
<span class="badge bg-danger text-white"><i class="mdi mdi-close"></i> Down</span>
{% else %}
<span class="badge bg-secondary text-white"><i class="mdi mdi-help"></i> Unknown</span>
{% endif %}
</td>
</tr>
<tr>
<th style="padding-left: 0.75rem;">Hostname</th>
<td>{{ libre_device.hostname }}</td>
</tr>
<tr>
<th style="padding-left: 0.75rem;">ID</th>
<td>{{ libre_device.device_id }}</td>
</tr>
<tr>
<th style="padding-left: 0.75rem;">IP</th>
<td>{{ libre_device.ip|default:"—" }}</td>
</tr>
<tr>
<th style="padding-left: 0.75rem;">Location</th>
<td>{{ libre_device.location|default:"—" }}</td>
</tr>
</tbody>
</table>

ghost Mar 2, 2026

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

Consider extracting repeated inline styles to CSS classes.

The style="padding-left: 0.75rem;" pattern appears on multiple <th> and <td> elements. Consider using a utility class or defining a reusable style to improve maintainability.

Also applies to: 86-93

🤖 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/htmx/device_validation_details.html`
around lines 45 - 76, The template device_validation_details.html repeats inline
style "padding-left: 0.75rem;" across several <th> (and some <td>) elements;
replace those inline styles with a reusable CSS class (e.g., add
class="tbl-cell-pl" or use an existing utility like "pl-3") on the <th> and <td>
elements in the table markup and add the CSS rule (e.g., .tbl-cell-pl {
padding-left: 0.75rem; }) to the plugin's stylesheet so all occurrences
(including the Status/Hostname/ID/IP/Location rows) use the class instead of
inline style.

ghost 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/jobs.py (1)

110-117: ⚠️ Potential issue | 🟡 Minor

Persist resolved server key in job.data.

Storing raw server_key can leave None in metadata. Persist api.server_key so downstream cache-key reconstruction is always consistent.

🔧 Proposed fix
-            "server_key": server_key,
+            "server_key": api.server_key,
...
-            "server_key": server_key,
+            "server_key": api.server_key,

Also applies to: 244-250

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

In `@netbox_librenms_plugin/jobs.py` around lines 110 - 117, The job currently
stores the local variable server_key into self.job.data which can be None;
change the assignments where job metadata is built (the self.job.data dict at
the first block and the similar block around lines 244–250) to persist the
resolved API key from the API client (use api.server_key) instead of server_key
so downstream cache-key reconstruction uses the actual persisted key; update the
dict key "server_key" to be assigned api.server_key in both places (and ensure
api is in scope where you make the change).
♻️ Duplicate comments (2)
netbox_librenms_plugin/views/imports/actions.py (1)

1088-1107: ⚠️ Potential issue | 🟠 Major

sync_serial still performs conflict check and write outside a transaction.

This leaves a race window where concurrent requests can both pass the conflict check and then write conflicting serial state.

🛡️ Proposed fix
         elif action == "sync_serial":
             # Sync serial number from LibreNMS
             incoming_serial = libre_device.get("serial") or ""
             if incoming_serial and incoming_serial != "-":
-                # Check for serial ownership conflict
-                conflict_device = Device.objects.filter(serial=incoming_serial).exclude(pk=existing_device.pk).first()
-                if conflict_device:
-                    logger.warning(
-                        f"Serial sync blocked: '{incoming_serial}' already assigned to "
-                        f"'{conflict_device.name}' (pk={conflict_device.pk})"
-                    )
-                    return HttpResponse(
-                        f"Serial conflict: '{escape(incoming_serial)}' is already assigned to device "
-                        f"'{escape(conflict_device.name)}' (ID: {conflict_device.pk})",
-                        status=409,
-                    )
-                existing_device.serial = incoming_serial
-                if err := _save_device(existing_device):
-                    return err
-                logger.info(f"Synced serial on '{existing_device.name}' to {incoming_serial}")
+                with transaction.atomic():
+                    locked_device = Device.objects.select_for_update().get(pk=existing_device.pk)
+                    conflict_device = (
+                        Device.objects.select_for_update()
+                        .filter(serial=incoming_serial)
+                        .exclude(pk=locked_device.pk)
+                        .first()
+                    )
+                    if conflict_device:
+                        logger.warning(
+                            f"Serial sync blocked: '{incoming_serial}' already assigned to "
+                            f"'{conflict_device.name}' (pk={conflict_device.pk})"
+                        )
+                        return HttpResponse(
+                            f"Serial conflict: '{escape(incoming_serial)}' is already assigned to device "
+                            f"'{escape(conflict_device.name)}' (ID: {conflict_device.pk})",
+                            status=409,
+                        )
+                    locked_device.serial = incoming_serial
+                    if err := _save_device(locked_device):
+                        return err
+                    logger.info(f"Synced serial on '{locked_device.name}' to {incoming_serial}")
🤖 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 1088 - 1107,
The sync_serial branch currently checks for serial conflicts and writes outside
a transaction, allowing a race; wrap the conflict-check-and-update in a DB
transaction (e.g., transaction.atomic) and acquire appropriate row locks (use
select_for_update on the target existing_device and/or query conflicting Device
rows) before re-checking for an existing serial, then perform the assignment and
call _save_device inside that transaction so the conflict is re-validated under
lock and concurrent requests cannot both succeed.
netbox_librenms_plugin/views/imports/list.py (1)

266-277: ⚠️ Potential issue | 🟠 Major

Submitted naming toggles are still ignored when user prefs are unset.

Both blocks only consider submitted toggle values when a user pref exists. When prefs are None, request-submitted toggles are dropped and defaults are used, causing inconsistent naming/cache behavior.

♻️ Proposed fix
-            _use_sysname = (
-                _use_sysname_pref
-                if _use_sysname_pref is not None
-                else (getattr(settings, "use_sysname_default", True) if settings else True)
-            )
-            _strip_domain = (
-                _strip_domain_pref
-                if _strip_domain_pref is not None
-                else (getattr(settings, "strip_domain_default", False) if settings else False)
-            )
+            submitted_use_sysname = self._filter_form_data.get("use_sysname_toggle")
+            submitted_strip_domain = self._filter_form_data.get("strip_domain_toggle")
+            if submitted_use_sysname is not None:
+                _use_sysname = submitted_use_sysname
+            elif _use_sysname_pref is not None:
+                _use_sysname = _use_sysname_pref
+            else:
+                _use_sysname = getattr(settings, "use_sysname_default", True) if settings else True
+
+            if submitted_strip_domain is not None:
+                _strip_domain = submitted_strip_domain
+            elif _strip_domain_pref is not None:
+                _strip_domain = _strip_domain_pref
+            else:
+                _strip_domain = getattr(settings, "strip_domain_default", False) if settings else False
...
-        use_sysname = (
-            data_source.get("use_sysname_toggle", use_sysname_pref)
-            if use_sysname_pref is not None
-            else (getattr(_settings, "use_sysname_default", True) if _settings else True)
-        )
-        strip_domain = (
-            data_source.get("strip_domain_toggle", strip_domain_pref)
-            if strip_domain_pref is not None
-            else (getattr(_settings, "strip_domain_default", False) if _settings else False)
-        )
+        use_sysname = data_source.get("use_sysname_toggle")
+        if use_sysname is None:
+            use_sysname = (
+                use_sysname_pref
+                if use_sysname_pref is not None
+                else (getattr(_settings, "use_sysname_default", True) if _settings else True)
+            )
+
+        strip_domain = data_source.get("strip_domain_toggle")
+        if strip_domain is None:
+            strip_domain = (
+                strip_domain_pref
+                if strip_domain_pref is not None
+                else (getattr(_settings, "strip_domain_default", False) if _settings else False)
+            )

Also applies to: 446-455

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

In `@netbox_librenms_plugin/views/imports/list.py` around lines 266 - 277, The
current logic only applies submitted toggle values when a user preference
exists, so when _use_sysname_pref or _strip_domain_pref is None the code ignores
request-submitted toggles and falls straight to settings/defaults; change the
assignment of _use_sysname and _strip_domain to first check the
request-submitted toggle (e.g., request.GET/POST param or form field for
"use_sysname" and "strip_domain") when the corresponding pref is None, then fall
back to getattr(settings, "..._default", ...) and finally to the hardcoded
default; update the same pattern in the other occurrence (lines referencing the
same variables around 446-455) and use the unique symbols _use_sysname_pref,
_strip_domain_pref, _use_sysname, _strip_domain to locate the code.
🤖 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/bulk_import.py`:
- Around line 192-197: The current vc_domain key uses device_id (vc_domain =
f"librenms-{device_id}") which causes duplicate VC creation for stack members;
change vc_domain to be stack-scoped by deriving a stack identifier from the
device record (e.g., stack_id, stack_master_id or another stack-unique field on
the device) and build the key like f"librenms-{stack_id}" (falling back to
device_id only if no stack identifier exists), then use that new vc_domain when
checking/adding to processed_vc_domains before creation; update references in
this block (vc_domain, processed_vc_domains, device_id) accordingly so all stack
members dedupe correctly.

In `@netbox_librenms_plugin/views/imports/actions.py`:
- Around line 50-66: The parsing only checks hyphenated keys (e.g.,
"use-sysname-toggle" and "strip-domain-toggle") so underscore or hidden-field
variants are ignored; update the logic around use_sysname and strip_domain to
look for both hyphenated and underscored keys in request.POST then request.GET
(e.g., check "use-sysname-toggle" OR "use_sysname-toggle") before falling back
to get_user_pref and LibreNMSSettings, ensuring you use the same normalized
boolean evaluation (== "on") for any found key.

---

Outside diff comments:
In `@netbox_librenms_plugin/jobs.py`:
- Around line 110-117: The job currently stores the local variable server_key
into self.job.data which can be None; change the assignments where job metadata
is built (the self.job.data dict at the first block and the similar block around
lines 244–250) to persist the resolved API key from the API client (use
api.server_key) instead of server_key so downstream cache-key reconstruction
uses the actual persisted key; update the dict key "server_key" to be assigned
api.server_key in both places (and ensure api is in scope where you make the
change).

---

Duplicate comments:
In `@netbox_librenms_plugin/views/imports/actions.py`:
- Around line 1088-1107: The sync_serial branch currently checks for serial
conflicts and writes outside a transaction, allowing a race; wrap the
conflict-check-and-update in a DB transaction (e.g., transaction.atomic) and
acquire appropriate row locks (use select_for_update on the target
existing_device and/or query conflicting Device rows) before re-checking for an
existing serial, then perform the assignment and call _save_device inside that
transaction so the conflict is re-validated under lock and concurrent requests
cannot both succeed.

In `@netbox_librenms_plugin/views/imports/list.py`:
- Around line 266-277: The current logic only applies submitted toggle values
when a user preference exists, so when _use_sysname_pref or _strip_domain_pref
is None the code ignores request-submitted toggles and falls straight to
settings/defaults; change the assignment of _use_sysname and _strip_domain to
first check the request-submitted toggle (e.g., request.GET/POST param or form
field for "use_sysname" and "strip_domain") when the corresponding pref is None,
then fall back to getattr(settings, "..._default", ...) and finally to the
hardcoded default; update the same pattern in the other occurrence (lines
referencing the same variables around 446-455) and use the unique symbols
_use_sysname_pref, _strip_domain_pref, _use_sysname, _strip_domain to locate the
code.

ℹ️ Review info

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

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 2d26019 and 3d67b40.

📒 Files selected for processing (9)
  • netbox_librenms_plugin/import_utils/bulk_import.py
  • netbox_librenms_plugin/import_utils/cache.py
  • netbox_librenms_plugin/jobs.py
  • netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_validation_details.html
  • netbox_librenms_plugin/tests/test_background_jobs.py
  • netbox_librenms_plugin/tests/test_import_utils.py
  • netbox_librenms_plugin/views/imports/actions.py
  • netbox_librenms_plugin/views/imports/list.py
  • netbox_librenms_plugin/views/sync/device_fields.py
📜 Review details
🧰 Additional context used
📓 Path-based instructions (6)
**/*.py

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

**/*.py: Reuse the librenms_api.py LibreNMS client instead of making new requests calls; it handles multi-server configs via LibreNMSSettings model and the servers plugin config, plus caching
Always call LibreNMSAPI.get_librenms_id to retrieve the device/VM LibreNMS mapping via the librenms_id custom field instead of touching the field directly
Matching must be exact-only for site, platform, device type, and role; do not add fuzzy matching; use the functions find_matching_site, match_librenms_hardware_to_device_type, and find_matching_platform from utils.py

Files:

  • netbox_librenms_plugin/import_utils/cache.py
  • netbox_librenms_plugin/views/imports/list.py
  • netbox_librenms_plugin/jobs.py
  • netbox_librenms_plugin/tests/test_background_jobs.py
  • netbox_librenms_plugin/views/sync/device_fields.py
  • netbox_librenms_plugin/views/imports/actions.py
  • netbox_librenms_plugin/import_utils/bulk_import.py
**/views/imports/**

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

**/views/imports/**: Use Job UUID (job.job_id) for RQ API endpoints: /api/core/background-tasks/{uuid}/
Use Job PK (job.pk) for database endpoints and result loading
Check rq_job.is_stopped or rq_job.is_failed flags in Redis for cancellation detection, not database status
Call /api/core/background-tasks/{uuid}/stop/ to stop RQ job in the job cancellation flow
Call plugin's sync endpoint /api/plugins/librenms_plugin/jobs/{pk}/sync-status/ to update database after stopping RQ job
Poll /api/core/background-tasks/{uuid}/ for real-time RQ status instead of polling the database
api/views.py::sync_job_status() must sync database Job status with RQ job status because NetBox worker doesn't always update DB when jobs stop before processing starts

Files:

  • netbox_librenms_plugin/views/imports/list.py
  • netbox_librenms_plugin/views/imports/actions.py
netbox_librenms_plugin/templates/**/*.html

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

netbox_librenms_plugin/templates/**/*.html: HTMX 2.x is the primary async layer. Table row updates should return <tr hx-swap-oob="true">.
Avoid outerHTML swaps in HTMX; use OOB or targeted innerHTML swaps to keep table layout intact.
Modals use Tabler (Bootstrap-like) but without bootstrap.Modal helpers.
Modal buttons should target the htmx-modal-content element. JavaScript in librenms_import.html toggles the wrapper; do not reintroduce data-bs-toggle or duplicate modal IDs.
Keep <select class="device-role-select"> markup stable to preserve JavaScript hook-up with TomSelect decorators.
Styling assumes Tabler defaults. Do not add table-responsive wrappers as they were deliberately removed to prevent dropdown clipping.

Files:

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

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

HTMX fragments should live in templates/netbox_librenms_plugin/htmx/. Keep server responses and HTMX targets in sync.

Files:

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

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

Templates should live in templates/netbox_librenms_plugin/. Reusable template includes should be placed under inc/ subdirectory.

Files:

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

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

**/jobs.py: Background jobs must use NetBox's JobRunner base class (netbox.jobs.JobRunner) for long-running operations like device filtering with VC detection
Jobs must run via Redis Queue (RQ) in Redis, separate from the database Job model, and real-time status must be checked via RQ, not the database
RQ status values must be: queued, started, finished, stopped, failed (NOT completed)
Database Job status values must be: pending, scheduled, running, completed, failed, errored (NO cancelled status exists)

Files:

  • netbox_librenms_plugin/jobs.py
🧠 Learnings (30)
📓 Common learnings
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-02-15T15:38:54.189Z
Learning: Applies to views/imports/**/*.py : REST endpoints for imports live in `views/imports/actions.py` with the list view in `views/imports/list.py`, surface via `urls.py`, and emit HTMX fragments in `templates/netbox_librenms_plugin/htmx/`; keep server responses and HTMX targets in sync
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/testing.instructions.md:0-0
Timestamp: 2026-02-15T15:39:20.744Z
Learning: Applies to tests/**/*.py : Test coverage mapping: `librenms_api.py` → `test_librenms_api.py`, `import_utils.py`/`import_validation_helpers.py`/`utils.py` → `test_import_utils.py`/`test_import_validation_helpers.py`/`test_utils.py`, `jobs.py`/`views/imports/list.py` → `test_background_jobs.py`
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-02-15T15:38:54.189Z
Learning: Follow sync pipeline flow: fetch LibreNMS data via `librenms_api.py`, cache it using `CacheMixin`, build comparison tables in `tables/`, and render HTMX fragments in `templates/netbox_librenms_plugin/htmx/`
📚 Learning: 2026-02-15T15:39:20.744Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/testing.instructions.md:0-0
Timestamp: 2026-02-15T15:39:20.744Z
Learning: Applies to tests/test_import_utils.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/import_utils/cache.py
  • netbox_librenms_plugin/tests/test_background_jobs.py
  • netbox_librenms_plugin/import_utils/bulk_import.py
📚 Learning: 2026-02-15T15:39:20.744Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/testing.instructions.md:0-0
Timestamp: 2026-02-15T15:39:20.744Z
Learning: Applies to tests/test_background_jobs.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/views/imports/list.py
  • netbox_librenms_plugin/tests/test_background_jobs.py
  • netbox_librenms_plugin/views/imports/actions.py
📚 Learning: 2026-02-15T15:39:20.744Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/testing.instructions.md:0-0
Timestamp: 2026-02-15T15:39:20.744Z
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/views/imports/list.py
  • netbox_librenms_plugin/tests/test_background_jobs.py
  • netbox_librenms_plugin/import_utils/bulk_import.py
📚 Learning: 2026-02-15T15:38:54.189Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-02-15T15:38:54.189Z
Learning: Applies to views/imports/**/*.py : REST endpoints for imports live in `views/imports/actions.py` with the list view in `views/imports/list.py`, surface via `urls.py`, and emit HTMX fragments in `templates/netbox_librenms_plugin/htmx/`; keep server responses and HTMX targets in sync

Applied to files:

  • netbox_librenms_plugin/views/imports/list.py
  • netbox_librenms_plugin/views/sync/device_fields.py
  • netbox_librenms_plugin/views/imports/actions.py
  • netbox_librenms_plugin/import_utils/bulk_import.py
📚 Learning: 2026-02-15T15:38:54.189Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-02-15T15:38:54.189Z
Learning: Applies to {jobs.py,views/imports/**/*.py} : Background job files and import views follow conventions documented in `.github/instructions/background-jobs.instructions.md`

Applied to files:

  • netbox_librenms_plugin/views/imports/list.py
📚 Learning: 2026-02-15T15:39:11.578Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/frontend.instructions.md:0-0
Timestamp: 2026-02-15T15:39:11.578Z
Learning: Applies to netbox_librenms_plugin/templates/**/*.html : Modal buttons should target the `htmx-modal-content` element. 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/htmx/device_validation_details.html
📚 Learning: 2026-02-15T15:39:11.578Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/frontend.instructions.md:0-0
Timestamp: 2026-02-15T15:39:11.578Z
Learning: Applies to netbox_librenms_plugin/templates/**/*.html : Avoid `outerHTML` swaps in HTMX; use OOB 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/views/imports/actions.py
📚 Learning: 2026-02-15T15:39:11.578Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/frontend.instructions.md:0-0
Timestamp: 2026-02-15T15:39:11.578Z
Learning: Applies to netbox_librenms_plugin/templates/**/*.html : HTMX 2.x is the primary async layer. Table row updates should return `<tr hx-swap-oob="true">`.

Applied to files:

  • netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_validation_details.html
  • netbox_librenms_plugin/views/imports/actions.py
📚 Learning: 2026-02-15T15:39:11.578Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/frontend.instructions.md:0-0
Timestamp: 2026-02-15T15:39:11.578Z
Learning: Applies to netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/**/*.html : HTMX fragments should live in `templates/netbox_librenms_plugin/htmx/`. Keep server responses and HTMX targets in sync.

Applied to files:

  • netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_validation_details.html
  • netbox_librenms_plugin/views/imports/actions.py
📚 Learning: 2026-02-15T15:39:11.578Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/frontend.instructions.md:0-0
Timestamp: 2026-02-15T15:39:11.578Z
Learning: Applies to netbox_librenms_plugin/templates/**/*.html : Keep `<select class="device-role-select">` markup stable to preserve JavaScript hook-up with TomSelect decorators.

Applied to files:

  • netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_validation_details.html
  • netbox_librenms_plugin/views/imports/actions.py
📚 Learning: 2026-02-15T15:39:11.578Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/frontend.instructions.md:0-0
Timestamp: 2026-02-15T15:39:11.578Z
Learning: Applies to netbox_librenms_plugin/templates/**/*.html : Modals use Tabler (Bootstrap-like) but without `bootstrap.Modal` helpers.

Applied to files:

  • netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_validation_details.html
📚 Learning: 2026-02-15T15:39:11.578Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/frontend.instructions.md:0-0
Timestamp: 2026-02-15T15:39:11.578Z
Learning: Applies to netbox_librenms_plugin/templates/netbox_librenms_plugin/*sync*.html : Sync pages should extend `librenms_sync_base.html`.

Applied to files:

  • netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_validation_details.html
  • netbox_librenms_plugin/views/sync/device_fields.py
  • netbox_librenms_plugin/views/imports/actions.py
📚 Learning: 2026-02-15T15:38:54.189Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-02-15T15:38:54.189Z
Learning: Applies to templates/**/*.html : Reuse template includes under `templates/netbox_librenms_plugin/inc/` and ensure sync pages extend `librenms_sync_base.html`

Applied to files:

  • netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_validation_details.html
  • netbox_librenms_plugin/views/sync/device_fields.py
📚 Learning: 2026-02-15T15:39:11.578Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/frontend.instructions.md:0-0
Timestamp: 2026-02-15T15:39:11.578Z
Learning: Applies to netbox_librenms_plugin/templates/**/*.html : Styling assumes Tabler defaults. Do not add `table-responsive` wrappers as they were deliberately removed to prevent dropdown clipping.

Applied to files:

  • netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_validation_details.html
📚 Learning: 2026-02-15T15:39:03.405Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/background-jobs.instructions.md:0-0
Timestamp: 2026-02-15T15:39:03.405Z
Learning: Applies to **/jobs.py : Background jobs must use NetBox's `JobRunner` base class (`netbox.jobs.JobRunner`) for long-running operations like device filtering with VC detection

Applied to files:

  • netbox_librenms_plugin/jobs.py
  • netbox_librenms_plugin/tests/test_background_jobs.py
📚 Learning: 2026-02-15T15:39:20.744Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/testing.instructions.md:0-0
Timestamp: 2026-02-15T15:39:20.744Z
Learning: Applies to tests/test_background_jobs.py : Instantiate `JobRunner` subclasses using `object.__new__(JobClass)` to bypass `__init__`, then set `job.job = MagicMock()` and `job.logger = MagicMock()`

Applied to files:

  • netbox_librenms_plugin/tests/test_background_jobs.py
📚 Learning: 2026-02-15T15:38:54.189Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-02-15T15:38:54.189Z
Learning: Follow sync pipeline flow: fetch LibreNMS data via `librenms_api.py`, cache it using `CacheMixin`, build comparison tables in `tables/`, and render HTMX fragments in `templates/netbox_librenms_plugin/htmx/`

Applied to files:

  • netbox_librenms_plugin/views/sync/device_fields.py
  • netbox_librenms_plugin/import_utils/bulk_import.py
📚 Learning: 2026-02-15T15:38:54.189Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-02-15T15:38:54.189Z
Learning: Applies to {navigation.py,urls.py,api/**} : Respect NetBox plugin APIs in `navigation.py`, `urls.py`, and `api/` directories

Applied to files:

  • netbox_librenms_plugin/views/sync/device_fields.py
📚 Learning: 2026-02-15T15:38:54.189Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-02-15T15:38:54.189Z
Learning: Applies to **/*.py : Matching must be exact-only for site, platform, device type, and role; do not add fuzzy matching; use the functions `find_matching_site`, `match_librenms_hardware_to_device_type`, and `find_matching_platform` from `utils.py`

Applied to files:

  • netbox_librenms_plugin/views/imports/actions.py
📚 Learning: 2026-02-15T15:38:54.189Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-02-15T15:38:54.189Z
Learning: Applies to **/*.py : Always call `LibreNMSAPI.get_librenms_id` to retrieve the device/VM LibreNMS mapping via the `librenms_id` custom field instead of touching the field directly

Applied to files:

  • netbox_librenms_plugin/views/imports/actions.py
  • netbox_librenms_plugin/import_utils/bulk_import.py
📚 Learning: 2026-02-15T15:39:03.405Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/background-jobs.instructions.md:0-0
Timestamp: 2026-02-15T15:39:03.405Z
Learning: Applies to **/views/imports/** : Check `rq_job.is_stopped` or `rq_job.is_failed` flags in Redis for cancellation detection, not database status

Applied to files:

  • netbox_librenms_plugin/import_utils/bulk_import.py
📚 Learning: 2026-02-15T15:39:03.405Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/background-jobs.instructions.md:0-0
Timestamp: 2026-02-15T15:39:03.405Z
Learning: Applies to **/views/imports/** : `api/views.py::sync_job_status()` must sync database Job status with RQ job status because NetBox worker doesn't always update DB when jobs stop before processing starts

Applied to files:

  • netbox_librenms_plugin/import_utils/bulk_import.py
📚 Learning: 2026-02-15T15:39:03.405Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/background-jobs.instructions.md:0-0
Timestamp: 2026-02-15T15:39:03.405Z
Learning: Applies to **/views/imports/** : Call `/api/core/background-tasks/{uuid}/stop/` to stop RQ job in the job cancellation flow

Applied to files:

  • netbox_librenms_plugin/import_utils/bulk_import.py
📚 Learning: 2026-02-15T15:39:03.405Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/background-jobs.instructions.md:0-0
Timestamp: 2026-02-15T15:39:03.405Z
Learning: Applies to **/views/imports/** : Call plugin's sync endpoint `/api/plugins/librenms_plugin/jobs/{pk}/sync-status/` to update database after stopping RQ job

Applied to files:

  • netbox_librenms_plugin/import_utils/bulk_import.py
📚 Learning: 2026-02-15T15:39:03.405Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/background-jobs.instructions.md:0-0
Timestamp: 2026-02-15T15:39:03.405Z
Learning: Applies to **/jobs.py : Jobs must run via Redis Queue (RQ) in Redis, separate from the database Job model, and real-time status must be checked via RQ, not the database

Applied to files:

  • netbox_librenms_plugin/import_utils/bulk_import.py
📚 Learning: 2026-02-15T15:39:03.405Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/background-jobs.instructions.md:0-0
Timestamp: 2026-02-15T15:39:03.405Z
Learning: Applies to **/views/imports/** : Poll `/api/core/background-tasks/{uuid}/` for real-time RQ status instead of polling the database

Applied to files:

  • netbox_librenms_plugin/import_utils/bulk_import.py
📚 Learning: 2026-02-15T15:39:03.405Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/background-jobs.instructions.md:0-0
Timestamp: 2026-02-15T15:39:03.405Z
Learning: Applies to **/views/imports/** : Use Job UUID (`job.job_id`) for RQ API endpoints: `/api/core/background-tasks/{uuid}/`

Applied to files:

  • netbox_librenms_plugin/import_utils/bulk_import.py
📚 Learning: 2026-02-15T15:39:03.405Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/background-jobs.instructions.md:0-0
Timestamp: 2026-02-15T15:39:03.405Z
Learning: Applies to **/jobs.py : Database Job status values must be: `pending`, `scheduled`, `running`, `completed`, `failed`, `errored` (NO `cancelled` status exists)

Applied to files:

  • netbox_librenms_plugin/import_utils/bulk_import.py
📚 Learning: 2026-02-15T15:39:03.405Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/background-jobs.instructions.md:0-0
Timestamp: 2026-02-15T15:39:03.405Z
Learning: Applies to **/jobs.py : RQ status values must be: `queued`, `started`, `finished`, `stopped`, `failed` (NOT `completed`)

Applied to files:

  • netbox_librenms_plugin/import_utils/bulk_import.py
🧬 Code graph analysis (1)
netbox_librenms_plugin/import_utils/cache.py (1)
netbox_librenms_plugin/views/imports/list.py (1)
  • get (128-370)
🔇 Additional comments (11)
netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_validation_details.html (7)

23-24: Modal close button still uses data-bs-dismiss="modal".

This was previously flagged. The close buttons on lines 23 and 612 use Bootstrap-style dismissal which conflicts with the HTMX/Tabler modal pattern. As per coding guidelines, "Modal buttons should target the htmx-modal-content element."


50-56: Good defensive status comparison.

The dual type check (== 1 or == "1") properly handles the API returning status as either integer or string, aligning with the PR's status normalization goal.


116-126: LGTM on form patterns and accessibility.

Forms consistently include CSRF tokens, use appropriate hx-swap="none" for action endpoints, and icon-only buttons have aria-label attributes. External links properly include rel="noopener noreferrer".


469-490: Force-action checkbox pattern is correctly implemented.

The JavaScript enable/disable logic via querySelector('.force-action-btn') is properly scoped—the checkbox and the corresponding button class only exist when validation.device_type_mismatch is true, so the selector will always find the correct button within the form context.


45-76: Repeated inline styles could be extracted to a CSS class.

The style="padding-left: 0.75rem;" pattern appears on numerous <th> and <td> elements throughout the template. This was previously noted as a maintainability improvement.


28-35: Good DRY pattern for URL computation.

Computing existing_device_url once at the top and reusing it throughout the template avoids repetition and ensures consistency in the VM vs. device routing logic.


604-609: Good conditional display for Full Sync Page link.

The "Full Sync Page" button is appropriately shown only when existing_match_type == 'librenms_id', directing users to the detailed sync view only when the device is properly linked.

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

281-290: Good transactional boundary for create-and-assign.

Creating Platform and assigning it to Device in one atomic block prevents partial state when either step fails.


426-435: Race-safe mapping removal path looks solid.

The lock + in-transaction re-check pattern is correct for avoiding stale-read deletes in concurrent requests.

netbox_librenms_plugin/import_utils/cache.py (1)

83-93: Nice hardening on cache stability.

Timezone normalization for cached_at and deterministic hash-based validated keys are both good reliability improvements.

Also applies to: 158-163

netbox_librenms_plugin/tests/test_background_jobs.py (1)

689-723: Good coverage for naming-aware cache key reconstruction.

The updated assertions around use_sysname and strip_domain close an important regression gap for background-job result loading.

Also applies to: 743-761

Comment thread netbox_librenms_plugin/import_utils/bulk_import.py Outdated
Comment thread netbox_librenms_plugin/views/imports/actions.py Outdated
… handling

- actions.py: check both hyphenated and underscore toggle key variants in
  _resolve_naming_preferences (e.g. 'use-sysname-toggle' and 'use_sysname-toggle')
- actions.py: add select_for_update() row lock inside link/update/update_serial
  transaction block; document DB-unique-constraint limitation
- actions.py sync_serial: wrap conflict-check-and-write in transaction.atomic()
  with select_for_update() to prevent concurrent serial assignment race
- device_fields.py RemoveServerMappingView: wrap full_clean()/save() in
  try/except for ValidationError and Exception; call transaction.set_rollback(),
  messages.error(), and redirect on failure instead of bubbling as 500
- bulk_import.py: fix vc_domain dedup key to use sorted member serials instead
  of per-device device_id, preventing duplicate VC creation for stack members
- list.py: add comment explaining why POST toggles are not re-read (they are
  saved via savePref AJAX before filter form submission)
- cache.py: add TODO comment noting location cache key should be server-scoped
  in a future PR
- tests: add TestResolveNamingPreferencesKeys, TestVCDomainStackDedup, and
  TestRemoveServerMappingViewErrorHandling; update DeviceConflictActionView
  tests to mock select_for_update() and transaction.atomic()
@marcinpsk

ghost commented Mar 3, 2026

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

ghost commented Mar 3, 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.

ghost 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

♻️ Duplicate comments (2)
netbox_librenms_plugin/views/imports/list.py (1)

260-282: ⚠️ Potential issue | 🟠 Major

Honor explicit toggle values before stored/default naming preferences.

Line 273 and Line 452 still allow explicit toggle input to be skipped when the saved preference is None, which can produce inconsistent naming and cache-key mismatches between sync/background paths.

🔧 Proposed fix
@@
-            _use_sysname_pref = get_user_pref(request, "plugins.netbox_librenms_plugin.use_sysname")
-            _strip_domain_pref = get_user_pref(request, "plugins.netbox_librenms_plugin.strip_domain")
-            _use_sysname = (
-                _use_sysname_pref
-                if _use_sysname_pref is not None
-                else (getattr(settings, "use_sysname_default", True) if settings else True)
-            )
-            _strip_domain = (
-                _strip_domain_pref
-                if _strip_domain_pref is not None
-                else (getattr(settings, "strip_domain_default", False) if settings else False)
-            )
+            def _coerce_toggle(value):
+                if value is None or isinstance(value, bool):
+                    return value
+                return str(value).strip().lower() in {"1", "true", "on", "yes"}
+
+            _use_sysname_pref = get_user_pref(request, "plugins.netbox_librenms_plugin.use_sysname")
+            _strip_domain_pref = get_user_pref(request, "plugins.netbox_librenms_plugin.strip_domain")
+            submitted_use_sysname = request.GET.get("use_sysname_toggle")
+            if submitted_use_sysname is None:
+                submitted_use_sysname = request.GET.get("use-sysname-toggle")
+            submitted_strip_domain = request.GET.get("strip_domain_toggle")
+            if submitted_strip_domain is None:
+                submitted_strip_domain = request.GET.get("strip-domain-toggle")
+
+            submitted_use_sysname = _coerce_toggle(submitted_use_sysname)
+            submitted_strip_domain = _coerce_toggle(submitted_strip_domain)
+
+            if submitted_use_sysname is not None:
+                _use_sysname = submitted_use_sysname
+            elif _use_sysname_pref is not None:
+                _use_sysname = _use_sysname_pref
+            else:
+                _use_sysname = getattr(settings, "use_sysname_default", True) if settings else True
+
+            if submitted_strip_domain is not None:
+                _strip_domain = submitted_strip_domain
+            elif _strip_domain_pref is not None:
+                _strip_domain = _strip_domain_pref
+            else:
+                _strip_domain = getattr(settings, "strip_domain_default", False) if settings else False
@@
-        use_sysname = (
-            data_source.get("use_sysname_toggle", use_sysname_pref)
-            if use_sysname_pref is not None
-            else (getattr(_settings, "use_sysname_default", True) if _settings else True)
-        )
-        strip_domain = (
-            data_source.get("strip_domain_toggle", strip_domain_pref)
-            if strip_domain_pref is not None
-            else (getattr(_settings, "strip_domain_default", False) if _settings else False)
-        )
+        submitted_use_sysname = data_source.get("use_sysname_toggle")
+        submitted_strip_domain = data_source.get("strip_domain_toggle")
+
+        if submitted_use_sysname is None:
+            submitted_use_sysname = self._request.GET.get("use_sysname_toggle")
+        if submitted_use_sysname is None:
+            submitted_use_sysname = self._request.GET.get("use-sysname-toggle")
+        if submitted_strip_domain is None:
+            submitted_strip_domain = self._request.GET.get("strip_domain_toggle")
+        if submitted_strip_domain is None:
+            submitted_strip_domain = self._request.GET.get("strip-domain-toggle")
+
+        submitted_use_sysname = _coerce_toggle(submitted_use_sysname)
+        submitted_strip_domain = _coerce_toggle(submitted_strip_domain)
+
+        if submitted_use_sysname is not None:
+            use_sysname = submitted_use_sysname
+        elif use_sysname_pref is not None:
+            use_sysname = use_sysname_pref
+        else:
+            use_sysname = getattr(_settings, "use_sysname_default", True) if _settings else True
+
+        if submitted_strip_domain is not None:
+            strip_domain = submitted_strip_domain
+        elif strip_domain_pref is not None:
+            strip_domain = strip_domain_pref
+        else:
+            strip_domain = getattr(_settings, "strip_domain_default", False) if _settings else False

Also applies to: 443-460

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

In `@netbox_librenms_plugin/views/imports/list.py` around lines 260 - 282, The
toggle resolution currently falls back to stored/default prefs before honoring
explicit form toggles, causing mismatches; update the logic in list.py around
get_user_pref, _use_sysname_pref, _strip_domain_pref, _use_sysname and
_strip_domain to first check for explicit toggle values from the incoming
request (the "use-sysname-toggle" and "strip-domain-toggle" parameters from
request.GET/request.POST as applicable), then fall back to
get_user_pref(request, ...) if no explicit toggle was provided, and finally use
LibreNMSSettings defaults (getattr(settings, "..._default", ...)) when both
explicit and saved prefs are absent; ensure the code reads request parameters
before consulting get_user_pref so explicit toggles always win.
netbox_librenms_plugin/views/imports/actions.py (1)

43-86: ⚠️ Potential issue | 🟠 Major

Naming preference parsing is still inconsistent with canonical import keys.

This helper ignores use_sysname / strip_domain, while Line 495-Line 496 use those keys for actual import execution. If only canonical keys are posted, preview/validation can diverge from import behavior.

Suggested patch
-    _USE_SYSNAME_KEYS = ("use-sysname-toggle", "use_sysname-toggle")
-    _STRIP_DOMAIN_KEYS = ("strip-domain-toggle", "strip_domain-toggle")
+    _USE_SYSNAME_KEYS = ("use-sysname-toggle", "use_sysname-toggle", "use_sysname")
+    _STRIP_DOMAIN_KEYS = ("strip-domain-toggle", "strip_domain-toggle", "strip_domain")
+    _TRUTHY = {"on", "true", "1", "yes"}

@@
-        use_sysname = _use_sysname_post == "on"
+        use_sysname = str(_use_sysname_post).strip().lower() in _TRUTHY
@@
-        use_sysname = _use_sysname_get == "on"
+        use_sysname = str(_use_sysname_get).strip().lower() in _TRUTHY
@@
-        strip_domain = _strip_domain_post == "on"
+        strip_domain = str(_strip_domain_post).strip().lower() in _TRUTHY
@@
-        strip_domain = _strip_domain_get == "on"
+        strip_domain = str(_strip_domain_get).strip().lower() in _TRUTHY
🤖 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 43 - 86, The
_resolve_naming_preferences helper ignores the canonical request keys
"use_sysname" and "strip_domain", causing preview/validation to differ from
import execution; update the _USE_SYSNAME_KEYS and _STRIP_DOMAIN_KEYS tuples in
function _resolve_naming_preferences to include the canonical key names
("use_sysname" and "strip_domain") alongside the existing hyphenated/underscored
variants so the next(... request.POST/GET ...) lookups will detect those posted
keys and produce consistent use_sysname/strip_domain values.
🤖 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`:
- Around line 72-76: Change the global location cache key to be scoped by
server_key so labels don't bleed between servers: replace the constant
location_cache_key = "librenms_locations_choices" and subsequent cache.get call
with a server-scoped key (e.g. build key using server_key like
f"librenms_locations_choices:{server_key}") and use that key for cache.get;
update any places that set/evict this cache to use the same server-scoped key
(refer to the location_cache_key variable and cached_locations usage to locate
the changes).

In `@netbox_librenms_plugin/views/imports/actions.py`:
- Around line 1016-1017: The locked re-fetches using
Device.objects.select_for_update().get (the one assigning existing_device and
the second similar call later) must be wrapped in try/except blocks to catch
Device.DoesNotExist; if the exception is raised, return the appropriate
controlled response (e.g., Http404 or a 409 conflict response consistent with
surrounding logic) instead of letting the exception propagate and cause a 500.
Locate the two select_for_update().get calls (the existing_device re-fetch and
the later locked get) and add try/except Device.DoesNotExist around each,
mapping the exception to the same error handling path used elsewhere in this
view.

In `@netbox_librenms_plugin/views/imports/list.py`:
- Around line 245-246: The cache pre-check currently logs a generic message and
swallows the exception (logger.debug("Cache check failed; proceeding without
cached result") followed by pass); update this to preserve the exception context
by either passing exc_info=True to logger.debug or logging the caught exception
variable (e.g., logger.debug("Cache check failed; proceeding without cached
result: %s", e, exc_info=True)). Locate the try/except block around the cache
pre-check (the lines containing logger.debug and the subsequent pass) and modify
the except clause to include the exception context in the log call rather than
dropping it.
- Around line 336-339: The logger.exception call in the LibreNMS import error
path is embedding the full request.user object (logger.exception(...,
getattr(request, "user", None))), which can serialize sensitive or large data;
change it to log a minimal identifier instead (e.g., getattr(request, "user",
None) and then extract a safe attribute such as user.id or user.username) and
pass that scalar as the format argument in the same logger.exception call
(reference the logger.exception invocation in this file to locate and update
it).

In `@netbox_librenms_plugin/views/sync/device_fields.py`:
- Around line 416-424: The guard in device_fields.py only checks
plugins_cfg.get("servers") and misses legacy single-server mode where
plugins_cfg contains "librenms_url" and the implicit "default" server is
configured, allowing removal of that mapping; update the check around
plugins_cfg, configured_servers and server_key so it also treats the legacy case
as configured: if plugins_cfg contains a non-empty "librenms_url" and
(configured_servers is empty or does not contain server_key) then treat
server_key == "default" as protected (i.e. call messages.error and redirect as
currently done). Use the existing symbols plugins_cfg,
django_settings.PLUGINS_CONFIG, configured_servers and server_key to implement
this additional condition.
- Around line 426-427: The select_for_update().get(pk=pk) inside the
transaction.atomic() can raise Device.DoesNotExist if the row was deleted after
initial validation; update the view to wrap the lock acquisition call
(Device.objects.select_for_update().get(pk=pk)) in a try/except catching
Device.DoesNotExist and handle it by redirecting/returning the same controlled
error response used earlier (with a helpful message) instead of allowing a 500;
ensure this occurs inside the same transaction block so the transaction is
rolled back cleanly and reuse the existing error/redirect logic used after the
initial validation to keep behavior consistent.

---

Duplicate comments:
In `@netbox_librenms_plugin/views/imports/actions.py`:
- Around line 43-86: The _resolve_naming_preferences helper ignores the
canonical request keys "use_sysname" and "strip_domain", causing
preview/validation to differ from import execution; update the _USE_SYSNAME_KEYS
and _STRIP_DOMAIN_KEYS tuples in function _resolve_naming_preferences to include
the canonical key names ("use_sysname" and "strip_domain") alongside the
existing hyphenated/underscored variants so the next(... request.POST/GET ...)
lookups will detect those posted keys and produce consistent
use_sysname/strip_domain values.

In `@netbox_librenms_plugin/views/imports/list.py`:
- Around line 260-282: The toggle resolution currently falls back to
stored/default prefs before honoring explicit form toggles, causing mismatches;
update the logic in list.py around get_user_pref, _use_sysname_pref,
_strip_domain_pref, _use_sysname and _strip_domain to first check for explicit
toggle values from the incoming request (the "use-sysname-toggle" and
"strip-domain-toggle" parameters from request.GET/request.POST as applicable),
then fall back to get_user_pref(request, ...) if no explicit toggle was
provided, and finally use LibreNMSSettings defaults (getattr(settings,
"..._default", ...)) when both explicit and saved prefs are absent; ensure the
code reads request parameters before consulting get_user_pref so explicit
toggles always win.

ℹ️ Review info

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

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 3d67b40 and 0524e31.

📒 Files selected for processing (7)
  • netbox_librenms_plugin/import_utils/bulk_import.py
  • netbox_librenms_plugin/import_utils/cache.py
  • netbox_librenms_plugin/tests/test_import_utils.py
  • netbox_librenms_plugin/tests/test_permissions.py
  • netbox_librenms_plugin/views/imports/actions.py
  • netbox_librenms_plugin/views/imports/list.py
  • netbox_librenms_plugin/views/sync/device_fields.py
📜 Review details
🧰 Additional context used
📓 Path-based instructions (2)
**/*.py

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

**/*.py: Reuse the librenms_api.py LibreNMS client instead of making new requests calls; it handles multi-server configs via LibreNMSSettings model and the servers plugin config, plus caching
Always call LibreNMSAPI.get_librenms_id to retrieve the device/VM LibreNMS mapping via the librenms_id custom field instead of touching the field directly
Matching must be exact-only for site, platform, device type, and role; do not add fuzzy matching; use the functions find_matching_site, match_librenms_hardware_to_device_type, and find_matching_platform from utils.py

Files:

  • netbox_librenms_plugin/views/sync/device_fields.py
  • netbox_librenms_plugin/views/imports/list.py
  • netbox_librenms_plugin/import_utils/cache.py
  • netbox_librenms_plugin/tests/test_permissions.py
  • netbox_librenms_plugin/views/imports/actions.py
  • netbox_librenms_plugin/import_utils/bulk_import.py
**/views/imports/**

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

**/views/imports/**: Use Job UUID (job.job_id) for RQ API endpoints: /api/core/background-tasks/{uuid}/
Use Job PK (job.pk) for database endpoints and result loading
Check rq_job.is_stopped or rq_job.is_failed flags in Redis for cancellation detection, not database status
Call /api/core/background-tasks/{uuid}/stop/ to stop RQ job in the job cancellation flow
Call plugin's sync endpoint /api/plugins/librenms_plugin/jobs/{pk}/sync-status/ to update database after stopping RQ job
Poll /api/core/background-tasks/{uuid}/ for real-time RQ status instead of polling the database
api/views.py::sync_job_status() must sync database Job status with RQ job status because NetBox worker doesn't always update DB when jobs stop before processing starts

Files:

  • netbox_librenms_plugin/views/imports/list.py
  • netbox_librenms_plugin/views/imports/actions.py
🧠 Learnings (27)
📓 Common learnings
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-02-15T15:38:54.189Z
Learning: Applies to views/imports/**/*.py : REST endpoints for imports live in `views/imports/actions.py` with the list view in `views/imports/list.py`, surface via `urls.py`, and emit HTMX fragments in `templates/netbox_librenms_plugin/htmx/`; keep server responses and HTMX targets in sync
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-02-15T15:38:54.189Z
Learning: Follow sync pipeline flow: fetch LibreNMS data via `librenms_api.py`, cache it using `CacheMixin`, build comparison tables in `tables/`, and render HTMX fragments in `templates/netbox_librenms_plugin/htmx/`
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/testing.instructions.md:0-0
Timestamp: 2026-02-15T15:39:20.744Z
Learning: Applies to tests/**/*.py : Test coverage mapping: `librenms_api.py` → `test_librenms_api.py`, `import_utils.py`/`import_validation_helpers.py`/`utils.py` → `test_import_utils.py`/`test_import_validation_helpers.py`/`test_utils.py`, `jobs.py`/`views/imports/list.py` → `test_background_jobs.py`
📚 Learning: 2026-02-15T15:39:11.578Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/frontend.instructions.md:0-0
Timestamp: 2026-02-15T15:39:11.578Z
Learning: Applies to netbox_librenms_plugin/templates/netbox_librenms_plugin/*sync*.html : Sync pages should extend `librenms_sync_base.html`.

Applied to files:

  • netbox_librenms_plugin/views/sync/device_fields.py
  • netbox_librenms_plugin/views/imports/actions.py
📚 Learning: 2026-02-15T15:38:54.189Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-02-15T15:38:54.189Z
Learning: Applies to views/imports/**/*.py : REST endpoints for imports live in `views/imports/actions.py` with the list view in `views/imports/list.py`, surface via `urls.py`, and emit HTMX fragments in `templates/netbox_librenms_plugin/htmx/`; keep server responses and HTMX targets in sync

Applied to files:

  • netbox_librenms_plugin/views/sync/device_fields.py
  • netbox_librenms_plugin/views/imports/list.py
  • netbox_librenms_plugin/tests/test_permissions.py
  • netbox_librenms_plugin/views/imports/actions.py
  • netbox_librenms_plugin/import_utils/bulk_import.py
📚 Learning: 2026-02-15T15:38:54.189Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-02-15T15:38:54.189Z
Learning: Applies to {navigation.py,urls.py,api/**} : Respect NetBox plugin APIs in `navigation.py`, `urls.py`, and `api/` directories

Applied to files:

  • netbox_librenms_plugin/views/sync/device_fields.py
📚 Learning: 2026-02-15T15:38:54.189Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-02-15T15:38:54.189Z
Learning: Follow sync pipeline flow: fetch LibreNMS data via `librenms_api.py`, cache it using `CacheMixin`, build comparison tables in `tables/`, and render HTMX fragments in `templates/netbox_librenms_plugin/htmx/`

Applied to files:

  • netbox_librenms_plugin/views/sync/device_fields.py
  • netbox_librenms_plugin/import_utils/bulk_import.py
📚 Learning: 2026-02-15T15:38:54.189Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-02-15T15:38:54.189Z
Learning: Applies to templates/**/*.html : Reuse template includes under `templates/netbox_librenms_plugin/inc/` and ensure sync pages extend `librenms_sync_base.html`

Applied to files:

  • netbox_librenms_plugin/views/sync/device_fields.py
📚 Learning: 2026-02-15T15:39:20.744Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/testing.instructions.md:0-0
Timestamp: 2026-02-15T15:39:20.744Z
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/views/imports/list.py
  • netbox_librenms_plugin/tests/test_permissions.py
  • netbox_librenms_plugin/import_utils/bulk_import.py
📚 Learning: 2026-02-15T15:38:54.189Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-02-15T15:38:54.189Z
Learning: Applies to {jobs.py,views/imports/**/*.py} : Background job files and import views follow conventions documented in `.github/instructions/background-jobs.instructions.md`

Applied to files:

  • netbox_librenms_plugin/views/imports/list.py
📚 Learning: 2026-02-15T15:39:20.744Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/testing.instructions.md:0-0
Timestamp: 2026-02-15T15:39:20.744Z
Learning: Applies to tests/test_background_jobs.py : Patch `cache` where imported: `netbox_librenms_plugin.views.imports.list.cache`, not `django.core.cache.cache`

Applied to files:

  • netbox_librenms_plugin/views/imports/list.py
  • netbox_librenms_plugin/import_utils/cache.py
  • netbox_librenms_plugin/tests/test_permissions.py
  • netbox_librenms_plugin/views/imports/actions.py
📚 Learning: 2026-02-15T15:39:03.405Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/background-jobs.instructions.md:0-0
Timestamp: 2026-02-15T15:39:03.405Z
Learning: Applies to **/jobs.py : Background jobs must use NetBox's `JobRunner` base class (`netbox.jobs.JobRunner`) for long-running operations like device filtering with VC detection

Applied to files:

  • netbox_librenms_plugin/views/imports/list.py
📚 Learning: 2026-02-15T15:39:20.744Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/testing.instructions.md:0-0
Timestamp: 2026-02-15T15:39:20.744Z
Learning: Applies to tests/test_import_utils.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/import_utils/cache.py
  • netbox_librenms_plugin/tests/test_permissions.py
  • netbox_librenms_plugin/import_utils/bulk_import.py
📚 Learning: 2026-02-15T15:39:20.744Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/testing.instructions.md:0-0
Timestamp: 2026-02-15T15:39:20.744Z
Learning: Applies to tests/**/*.py : Test coverage mapping: `librenms_api.py` → `test_librenms_api.py`, `import_utils.py`/`import_validation_helpers.py`/`utils.py` → `test_import_utils.py`/`test_import_validation_helpers.py`/`test_utils.py`, `jobs.py`/`views/imports/list.py` → `test_background_jobs.py`

Applied to files:

  • netbox_librenms_plugin/tests/test_permissions.py
📚 Learning: 2026-02-15T15:38:54.189Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-02-15T15:38:54.189Z
Learning: Applies to **/*.py : Matching must be exact-only for site, platform, device type, and role; do not add fuzzy matching; use the functions `find_matching_site`, `match_librenms_hardware_to_device_type`, and `find_matching_platform` from `utils.py`

Applied to files:

  • netbox_librenms_plugin/views/imports/actions.py
📚 Learning: 2026-02-15T15:38:54.189Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-02-15T15:38:54.189Z
Learning: Applies to **/*.py : Always call `LibreNMSAPI.get_librenms_id` to retrieve the device/VM LibreNMS mapping via the `librenms_id` custom field instead of touching the field directly

Applied to files:

  • netbox_librenms_plugin/views/imports/actions.py
  • netbox_librenms_plugin/import_utils/bulk_import.py
📚 Learning: 2026-02-15T15:39:11.578Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/frontend.instructions.md:0-0
Timestamp: 2026-02-15T15:39:11.578Z
Learning: Applies to netbox_librenms_plugin/templates/**/*.html : Avoid `outerHTML` swaps in HTMX; use OOB or targeted `innerHTML` swaps to keep table layout intact.

Applied to files:

  • netbox_librenms_plugin/views/imports/actions.py
📚 Learning: 2026-02-15T15:39:11.578Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/frontend.instructions.md:0-0
Timestamp: 2026-02-15T15:39:11.578Z
Learning: Applies to netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/**/*.html : HTMX fragments should live in `templates/netbox_librenms_plugin/htmx/`. Keep server responses and HTMX targets in sync.

Applied to files:

  • netbox_librenms_plugin/views/imports/actions.py
📚 Learning: 2026-02-15T15:39:11.578Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/frontend.instructions.md:0-0
Timestamp: 2026-02-15T15:39:11.578Z
Learning: Applies to netbox_librenms_plugin/templates/**/*.html : HTMX 2.x is the primary async layer. Table row updates should return `<tr hx-swap-oob="true">`.

Applied to files:

  • netbox_librenms_plugin/views/imports/actions.py
📚 Learning: 2026-02-15T15:39:11.578Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/frontend.instructions.md:0-0
Timestamp: 2026-02-15T15:39:11.578Z
Learning: Applies to netbox_librenms_plugin/templates/**/*.html : Keep `<select class="device-role-select">` markup stable to preserve JavaScript hook-up with TomSelect decorators.

Applied to files:

  • netbox_librenms_plugin/views/imports/actions.py
📚 Learning: 2026-02-15T15:39:03.405Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/background-jobs.instructions.md:0-0
Timestamp: 2026-02-15T15:39:03.405Z
Learning: Applies to **/views/imports/** : Check `rq_job.is_stopped` or `rq_job.is_failed` flags in Redis for cancellation detection, not database status

Applied to files:

  • netbox_librenms_plugin/import_utils/bulk_import.py
📚 Learning: 2026-02-15T15:39:03.405Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/background-jobs.instructions.md:0-0
Timestamp: 2026-02-15T15:39:03.405Z
Learning: Applies to **/views/imports/** : `api/views.py::sync_job_status()` must sync database Job status with RQ job status because NetBox worker doesn't always update DB when jobs stop before processing starts

Applied to files:

  • netbox_librenms_plugin/import_utils/bulk_import.py
📚 Learning: 2026-02-15T15:39:03.405Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/background-jobs.instructions.md:0-0
Timestamp: 2026-02-15T15:39:03.405Z
Learning: Applies to **/views/imports/** : Call `/api/core/background-tasks/{uuid}/stop/` to stop RQ job in the job cancellation flow

Applied to files:

  • netbox_librenms_plugin/import_utils/bulk_import.py
📚 Learning: 2026-02-15T15:39:03.405Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/background-jobs.instructions.md:0-0
Timestamp: 2026-02-15T15:39:03.405Z
Learning: Applies to **/views/imports/** : Call plugin's sync endpoint `/api/plugins/librenms_plugin/jobs/{pk}/sync-status/` to update database after stopping RQ job

Applied to files:

  • netbox_librenms_plugin/import_utils/bulk_import.py
📚 Learning: 2026-02-15T15:39:03.405Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/background-jobs.instructions.md:0-0
Timestamp: 2026-02-15T15:39:03.405Z
Learning: Applies to **/jobs.py : Jobs must run via Redis Queue (RQ) in Redis, separate from the database Job model, and real-time status must be checked via RQ, not the database

Applied to files:

  • netbox_librenms_plugin/import_utils/bulk_import.py
📚 Learning: 2026-02-15T15:39:03.405Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/background-jobs.instructions.md:0-0
Timestamp: 2026-02-15T15:39:03.405Z
Learning: Applies to **/views/imports/** : Poll `/api/core/background-tasks/{uuid}/` for real-time RQ status instead of polling the database

Applied to files:

  • netbox_librenms_plugin/import_utils/bulk_import.py
📚 Learning: 2026-02-15T15:39:03.405Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/background-jobs.instructions.md:0-0
Timestamp: 2026-02-15T15:39:03.405Z
Learning: Applies to **/views/imports/** : Use Job UUID (`job.job_id`) for RQ API endpoints: `/api/core/background-tasks/{uuid}/`

Applied to files:

  • netbox_librenms_plugin/import_utils/bulk_import.py
📚 Learning: 2026-02-15T15:39:03.405Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/background-jobs.instructions.md:0-0
Timestamp: 2026-02-15T15:39:03.405Z
Learning: Applies to **/jobs.py : Database Job status values must be: `pending`, `scheduled`, `running`, `completed`, `failed`, `errored` (NO `cancelled` status exists)

Applied to files:

  • netbox_librenms_plugin/import_utils/bulk_import.py
📚 Learning: 2026-02-15T15:39:03.405Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/background-jobs.instructions.md:0-0
Timestamp: 2026-02-15T15:39:03.405Z
Learning: Applies to **/jobs.py : RQ status values must be: `queued`, `started`, `finished`, `stopped`, `failed` (NOT `completed`)

Applied to files:

  • netbox_librenms_plugin/import_utils/bulk_import.py
🧬 Code graph analysis (4)
netbox_librenms_plugin/views/sync/device_fields.py (4)
netbox_librenms_plugin/utils.py (1)
  • match_librenms_hardware_to_device_type (302-367)
netbox_librenms_plugin/views/mixins.py (2)
  • LibreNMSAPIMixin (196-282)
  • librenms_api (217-231)
netbox_librenms_plugin/librenms_api.py (2)
  • get_librenms_id (173-231)
  • get_device_info (314-337)
netbox_librenms_plugin/views/base/librenms_sync_view.py (1)
  • get (26-47)
netbox_librenms_plugin/views/imports/list.py (5)
netbox_librenms_plugin/views/imports/actions.py (2)
  • get (761-781)
  • get (787-816)
netbox_librenms_plugin/views/base/librenms_sync_view.py (1)
  • get (26-47)
netbox_librenms_plugin/views/settings_views.py (1)
  • get (26-43)
netbox_librenms_plugin/models.py (1)
  • LibreNMSSettings (11-52)
netbox_librenms_plugin/utils.py (1)
  • get_user_pref (255-259)
netbox_librenms_plugin/import_utils/cache.py (1)
netbox_librenms_plugin/views/imports/list.py (1)
  • get (128-375)
netbox_librenms_plugin/tests/test_permissions.py (1)
netbox_librenms_plugin/views/sync/device_fields.py (8)
  • RemoveServerMappingView (389-448)
  • post (19-56)
  • post (66-110)
  • post (120-171)
  • post (181-238)
  • post (251-310)
  • post (320-386)
  • post (396-448)
🔇 Additional comments (3)
netbox_librenms_plugin/views/imports/list.py (1)

95-96: Good propagation of naming flags through job payload and cache keys.

The updates on Line 95-96, Line 114-115, Line 299-300, Line 471-472, and Line 486-487 consistently thread use_sysname/strip_domain through enqueue, cache reads, and cache metadata lookups. This prevents cross-preference cache collisions.

Also applies to: 114-115, 299-300, 471-472, 486-487

netbox_librenms_plugin/import_utils/bulk_import.py (1)

97-125: RQ-first cancellation handling is a solid improvement.

The stop-state check order and DB fallback are well-structured and reduce delayed cancellation behavior in bulk imports.

netbox_librenms_plugin/tests/test_permissions.py (1)

961-1038: Good addition: error-path tests for RemoveServerMappingView.

These cases materially improve confidence in graceful handling of validation failures and configured-server protection.

Comment thread netbox_librenms_plugin/import_utils/cache.py Outdated
Comment thread netbox_librenms_plugin/views/imports/actions.py Outdated
Comment thread netbox_librenms_plugin/views/imports/list.py Outdated
Comment thread netbox_librenms_plugin/views/imports/list.py
Comment thread netbox_librenms_plugin/views/sync/device_fields.py
Comment thread netbox_librenms_plugin/views/sync/device_fields.py Outdated
- cache.py + forms.py: scope location choices cache key by server_key
  (f"librenms_locations_choices:{server_key}") so labels from different
  LibreNMS servers don't bleed into each other's filter summaries
- list.py ~245: include exc_info=True in swallowed cache-check exception log
- list.py ~337: log user.username scalar instead of full user object to avoid
  serialising sensitive/large data in exception handler
- list.py ~451: fix reversed ternary for use_sysname/strip_domain — submitted
  form toggle now correctly takes priority over user pref which takes priority
  over settings default (was: pref check gated the toggle, ignoring toggle when
  pref was None)
- actions.py ~52: add plain "use_sysname" / "strip_domain" canonical keys to
  _USE_SYSNAME_KEYS / _STRIP_DOMAIN_KEYS so preview and import use the same
  resolution logic regardless of which key name the form sends
- actions.py ~1016 / ~1136: wrap select_for_update().get() in try/except
  Device.DoesNotExist and return 409 instead of propagating a 500
- actions.py ~1054 / ~1084: add select_for_update() to serial-conflict filter
  query inside transaction to reduce concurrent-assignment race window
- device_fields.py ~416: also protect "default" server_key when legacy
  single-server librenms_url is configured (was only checking servers dict)
- device_fields.py ~430: wrap select_for_update().get(pk=pk) in try/except
  Device.DoesNotExist and redirect with error message instead of 500
- bulk_import.py ~127: move progress log inside success path after the import
  completes; use idx+1 so the count reflects completed items
- tests: add positive-path removal test; update DeviceConflictActionView mocks
  to cover select_for_update().filter() chain

ghost 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: 9

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/forms.py (1)

728-747: 🧹 Nitpick | 🔵 Trivial

Complex default checkbox logic could be simplified.

The logic to apply use_background_job default only on initial load involves checking multiple conditions. Consider extracting this into a helper method for readability, or adding a brief inline comment explaining the intent.

🤖 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 728 - 747, Extract the complex
default-checkbox decision into a small helper on the form class (e.g.,
_should_set_use_background_job_default) that accepts the incoming data dict and
returns True when "use_background_job" should be set; move the current checks
(presence of "use_background_job", absence of "job_id", and no filter field
values for librenms_location/type/os/hostname/sysname/hardware) into that
helper, call it where the code currently sets data["use_background_job"] = "on",
and add a one-line docstring to the helper explaining "apply default only on
initial load (no filters, no job_id)".
🤖 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 74-85: The code creates LibreNMSAPI() twice—once to build
cache_key from api.librenms_url and again for the real API call; reuse the first
instance instead: instantiate LibreNMSAPI() once into api, compute server_id and
cache_key from api.librenms_url, check cache, and then use the same api for
subsequent API calls (referencing LibreNMSAPI, api, cache_key, and librenms_url)
to avoid redundant instantiation and overhead.
- Around line 315-322: DeviceTypeMappingImportForm and
ModuleTypeMappingImportForm must define their ForeignKey import fields using
CSVModelChoiceField so CSVs can reference human-readable identifiers instead of
DB IDs; add explicit fields netbox_device_type =
CSVModelChoiceField(queryset=DeviceType.objects.all(), to_field_name="model" or
"slug", help_text="NetBox device type model name") in
DeviceTypeMappingImportForm and netbox_module_type =
CSVModelChoiceField(queryset=ModuleType.objects.all(), to_field_name="model" or
"slug", help_text="NetBox module type model name") in
ModuleTypeMappingImportForm, leaving Meta.model/fields intact.

In `@netbox_librenms_plugin/import_utils/bulk_import.py`:
- Around line 186-188: The progress log in bulk_import.py uses idx which is
already 1-based (enumerate(..., start=1)), so the message
job.logger.info(f"Imported device {idx + 1} of {total}") is off-by-one; update
the logging call in the relevant function to use idx (not idx + 1) so the
message becomes "Imported device {idx} of {total}" while keeping job and total
as before.

In `@netbox_librenms_plugin/tests/test_permissions.py`:
- Around line 1073-1077: The test currently only checks the nested dict under
mock_locked.custom_field_data["librenms_id"], which can miss misspelled
top-level keys; update the assertions to explicitly verify the top-level keys of
mock_locked.custom_field_data (e.g., assert that "librenms_id" exists and that
no misspelled variant like "librenrenms_id" is present) or assert equality
against the exact expected mapping for mock_locked.custom_field_data (so the
entire custom_field_data dict matches the expected keys/values), while keeping
the existing assert that mock_locked.save.assert_called_once() to ensure save
was invoked.

In `@netbox_librenms_plugin/views/imports/actions.py`:
- Around line 59-62: The current logic sets use_sysname (and similarly
strip_domain) to True only when the posted/get value equals "on", which
misparses "true" or "1"; update the parsing around
_use_sysname_post/_use_sysname_get and _strip_domain_post/_strip_domain_get to
treat common truthy strings ("on", "true", "1") case-insensitively as True and
everything else as False, e.g., normalize the value to lowercase and check
membership in a truthy set before assigning to use_sysname/strip_domain so both
POST and GET branches behave consistently.
- Around line 1135-1145: The serial-conflict check in sync_serial is not
lock-consistent: after locking existing_device into locked_device you call
Device.objects.filter(serial=incoming_serial).exclude(pk=locked_device.pk).first()
without acquiring a row lock, allowing a race where two concurrent syncs both
pass and write the same serial. Change that query to acquire the same FOR UPDATE
lock (e.g., use .select_for_update() on the queryset) so the conflict_device
lookup is performed under the transaction lock, referencing the same variables
(locked_device, incoming_serial) and keeping the logic consistent with the
earlier locked read; optionally note adding a DB unique constraint on
Device.serial for full enforcement.

In `@netbox_librenms_plugin/views/sync/device_fields.py`:
- Around line 418-421: The code assumes plugins_cfg.get("servers") is iterable
which can raise if it's None or malformed; update the check around
plugins_cfg/configured_servers in the device_fields logic (where plugins_cfg,
configured_servers, legacy_url_configured and server_key are used) to first
coerce or validate configured_servers (e.g. set configured_servers =
plugins_cfg.get("servers") or {} or verify isinstance(configured_servers, (dict,
list, set, tuple))) and then perform membership testing only on validated
iterables (e.g. check server_key in configured_servers when it's a
dict/list/etc.), preserving the existing legacy_url_configured && server_key ==
"default" branch.
- Around line 291-299: The code currently builds error_str from the exception
and shows it to users via messages.error (see error_str, messages.error,
platform_name, and the redirect to device_librenms_sync/ pk); change this to log
the full exception server-side (use logging.getLogger(__name__) and
logger.exception or logger.error(..., exc_info=True)) and replace the
user-facing messages.error calls with a generic message like "Failed to assign
platform '{platform_name}'. Please contact an administrator." Apply the same
change to both occurrences that currently expose exception text so the
redirect/flow (redirect("plugins:netbox_librenms_plugin:device_librenms_sync",
pk=pk)) remains unchanged.
- Around line 389-390: The code in RemoveServerMappingView directly accesses
custom_field_data["librenms_id"]; replace those direct reads with the
centralized helper LibreNMSAPI.get_librenms_id(...) so retrieval honors the
project’s mapping access contract — update both occurrences referenced (the
retrieval around the validation/lookup prior to removal and the subsequent check
before modifying the mapping) to call LibreNMSAPI.get_librenms_id with the
Device/VM instance, handle a None/absent return the same way the current logic
expects, and then proceed to update/delete the mapping using the existing update
flow.

---

Outside diff comments:
In `@netbox_librenms_plugin/forms.py`:
- Around line 728-747: Extract the complex default-checkbox decision into a
small helper on the form class (e.g., _should_set_use_background_job_default)
that accepts the incoming data dict and returns True when "use_background_job"
should be set; move the current checks (presence of "use_background_job",
absence of "job_id", and no filter field values for
librenms_location/type/os/hostname/sysname/hardware) into that helper, call it
where the code currently sets data["use_background_job"] = "on", and add a
one-line docstring to the helper explaining "apply default only on initial load
(no filters, no job_id)".

ℹ️ Review info

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

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 0524e31 and 347938a.

📒 Files selected for processing (8)
  • netbox_librenms_plugin/forms.py
  • netbox_librenms_plugin/import_utils/bulk_import.py
  • netbox_librenms_plugin/import_utils/cache.py
  • netbox_librenms_plugin/tests/test_import_utils.py
  • netbox_librenms_plugin/tests/test_permissions.py
  • netbox_librenms_plugin/views/imports/actions.py
  • netbox_librenms_plugin/views/imports/list.py
  • netbox_librenms_plugin/views/sync/device_fields.py
📜 Review details
🧰 Additional context used
📓 Path-based instructions (2)
**/*.py

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

**/*.py: Reuse the librenms_api.py LibreNMS client instead of making new requests calls; it handles multi-server configs via LibreNMSSettings model and the servers plugin config, plus caching
Always call LibreNMSAPI.get_librenms_id to retrieve the device/VM LibreNMS mapping via the librenms_id custom field instead of touching the field directly
Matching must be exact-only for site, platform, device type, and role; do not add fuzzy matching; use the functions find_matching_site, match_librenms_hardware_to_device_type, and find_matching_platform from utils.py

Files:

  • netbox_librenms_plugin/views/sync/device_fields.py
  • netbox_librenms_plugin/views/imports/list.py
  • netbox_librenms_plugin/tests/test_permissions.py
  • netbox_librenms_plugin/import_utils/bulk_import.py
  • netbox_librenms_plugin/import_utils/cache.py
  • netbox_librenms_plugin/views/imports/actions.py
  • netbox_librenms_plugin/forms.py
**/views/imports/**

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

**/views/imports/**: Use Job UUID (job.job_id) for RQ API endpoints: /api/core/background-tasks/{uuid}/
Use Job PK (job.pk) for database endpoints and result loading
Check rq_job.is_stopped or rq_job.is_failed flags in Redis for cancellation detection, not database status
Call /api/core/background-tasks/{uuid}/stop/ to stop RQ job in the job cancellation flow
Call plugin's sync endpoint /api/plugins/librenms_plugin/jobs/{pk}/sync-status/ to update database after stopping RQ job
Poll /api/core/background-tasks/{uuid}/ for real-time RQ status instead of polling the database
api/views.py::sync_job_status() must sync database Job status with RQ job status because NetBox worker doesn't always update DB when jobs stop before processing starts

Files:

  • netbox_librenms_plugin/views/imports/list.py
  • netbox_librenms_plugin/views/imports/actions.py
🧠 Learnings (27)
📓 Common learnings
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-02-15T15:38:54.189Z
Learning: Applies to views/imports/**/*.py : REST endpoints for imports live in `views/imports/actions.py` with the list view in `views/imports/list.py`, surface via `urls.py`, and emit HTMX fragments in `templates/netbox_librenms_plugin/htmx/`; keep server responses and HTMX targets in sync
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-02-15T15:38:54.189Z
Learning: Follow sync pipeline flow: fetch LibreNMS data via `librenms_api.py`, cache it using `CacheMixin`, build comparison tables in `tables/`, and render HTMX fragments in `templates/netbox_librenms_plugin/htmx/`
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/testing.instructions.md:0-0
Timestamp: 2026-02-15T15:39:20.744Z
Learning: Applies to tests/**/*.py : Test coverage mapping: `librenms_api.py` → `test_librenms_api.py`, `import_utils.py`/`import_validation_helpers.py`/`utils.py` → `test_import_utils.py`/`test_import_validation_helpers.py`/`test_utils.py`, `jobs.py`/`views/imports/list.py` → `test_background_jobs.py`
📚 Learning: 2026-02-15T15:38:54.189Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-02-15T15:38:54.189Z
Learning: Applies to views/imports/**/*.py : REST endpoints for imports live in `views/imports/actions.py` with the list view in `views/imports/list.py`, surface via `urls.py`, and emit HTMX fragments in `templates/netbox_librenms_plugin/htmx/`; keep server responses and HTMX targets in sync

Applied to files:

  • netbox_librenms_plugin/views/sync/device_fields.py
  • netbox_librenms_plugin/views/imports/list.py
  • netbox_librenms_plugin/tests/test_permissions.py
  • netbox_librenms_plugin/import_utils/bulk_import.py
  • netbox_librenms_plugin/views/imports/actions.py
📚 Learning: 2026-02-15T15:39:11.578Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/frontend.instructions.md:0-0
Timestamp: 2026-02-15T15:39:11.578Z
Learning: Applies to netbox_librenms_plugin/templates/netbox_librenms_plugin/*sync*.html : Sync pages should extend `librenms_sync_base.html`.

Applied to files:

  • netbox_librenms_plugin/views/sync/device_fields.py
  • netbox_librenms_plugin/views/imports/actions.py
📚 Learning: 2026-02-15T15:38:54.189Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-02-15T15:38:54.189Z
Learning: Applies to {navigation.py,urls.py,api/**} : Respect NetBox plugin APIs in `navigation.py`, `urls.py`, and `api/` directories

Applied to files:

  • netbox_librenms_plugin/views/sync/device_fields.py
  • netbox_librenms_plugin/views/imports/actions.py
  • netbox_librenms_plugin/forms.py
📚 Learning: 2026-02-15T15:38:54.189Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-02-15T15:38:54.189Z
Learning: Follow sync pipeline flow: fetch LibreNMS data via `librenms_api.py`, cache it using `CacheMixin`, build comparison tables in `tables/`, and render HTMX fragments in `templates/netbox_librenms_plugin/htmx/`

Applied to files:

  • netbox_librenms_plugin/views/sync/device_fields.py
  • netbox_librenms_plugin/forms.py
📚 Learning: 2026-02-15T15:38:54.189Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-02-15T15:38:54.189Z
Learning: Applies to {jobs.py,views/imports/**/*.py} : Background job files and import views follow conventions documented in `.github/instructions/background-jobs.instructions.md`

Applied to files:

  • netbox_librenms_plugin/views/imports/list.py
📚 Learning: 2026-02-15T15:39:20.744Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/testing.instructions.md:0-0
Timestamp: 2026-02-15T15:39:20.744Z
Learning: Applies to tests/test_background_jobs.py : Patch `cache` where imported: `netbox_librenms_plugin.views.imports.list.cache`, not `django.core.cache.cache`

Applied to files:

  • netbox_librenms_plugin/views/imports/list.py
  • netbox_librenms_plugin/tests/test_permissions.py
  • netbox_librenms_plugin/import_utils/cache.py
  • netbox_librenms_plugin/views/imports/actions.py
📚 Learning: 2026-02-15T15:39:20.744Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/testing.instructions.md:0-0
Timestamp: 2026-02-15T15:39:20.744Z
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/views/imports/list.py
  • netbox_librenms_plugin/tests/test_permissions.py
  • netbox_librenms_plugin/import_utils/bulk_import.py
📚 Learning: 2026-02-15T15:39:03.405Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/background-jobs.instructions.md:0-0
Timestamp: 2026-02-15T15:39:03.405Z
Learning: Applies to **/jobs.py : Background jobs must use NetBox's `JobRunner` base class (`netbox.jobs.JobRunner`) for long-running operations like device filtering with VC detection

Applied to files:

  • netbox_librenms_plugin/views/imports/list.py
📚 Learning: 2026-02-15T15:39:20.744Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/testing.instructions.md:0-0
Timestamp: 2026-02-15T15:39:20.744Z
Learning: Applies to tests/**/*.py : Test coverage mapping: `librenms_api.py` → `test_librenms_api.py`, `import_utils.py`/`import_validation_helpers.py`/`utils.py` → `test_import_utils.py`/`test_import_validation_helpers.py`/`test_utils.py`, `jobs.py`/`views/imports/list.py` → `test_background_jobs.py`

Applied to files:

  • netbox_librenms_plugin/tests/test_permissions.py
📚 Learning: 2026-02-15T15:39:20.744Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/testing.instructions.md:0-0
Timestamp: 2026-02-15T15:39:20.744Z
Learning: Applies to tests/test_import_utils.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_permissions.py
  • netbox_librenms_plugin/import_utils/bulk_import.py
  • netbox_librenms_plugin/import_utils/cache.py
📚 Learning: 2026-02-15T15:38:54.189Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-02-15T15:38:54.189Z
Learning: Applies to **/*.py : Always call `LibreNMSAPI.get_librenms_id` to retrieve the device/VM LibreNMS mapping via the `librenms_id` custom field instead of touching the field directly

Applied to files:

  • netbox_librenms_plugin/import_utils/bulk_import.py
  • netbox_librenms_plugin/views/imports/actions.py
📚 Learning: 2026-02-15T15:39:03.405Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/background-jobs.instructions.md:0-0
Timestamp: 2026-02-15T15:39:03.405Z
Learning: Applies to **/views/imports/** : Check `rq_job.is_stopped` or `rq_job.is_failed` flags in Redis for cancellation detection, not database status

Applied to files:

  • netbox_librenms_plugin/import_utils/bulk_import.py
📚 Learning: 2026-02-15T15:39:03.405Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/background-jobs.instructions.md:0-0
Timestamp: 2026-02-15T15:39:03.405Z
Learning: Applies to **/views/imports/** : `api/views.py::sync_job_status()` must sync database Job status with RQ job status because NetBox worker doesn't always update DB when jobs stop before processing starts

Applied to files:

  • netbox_librenms_plugin/import_utils/bulk_import.py
📚 Learning: 2026-02-15T15:39:03.405Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/background-jobs.instructions.md:0-0
Timestamp: 2026-02-15T15:39:03.405Z
Learning: Applies to **/views/imports/** : Call `/api/core/background-tasks/{uuid}/stop/` to stop RQ job in the job cancellation flow

Applied to files:

  • netbox_librenms_plugin/import_utils/bulk_import.py
📚 Learning: 2026-02-15T15:39:03.405Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/background-jobs.instructions.md:0-0
Timestamp: 2026-02-15T15:39:03.405Z
Learning: Applies to **/views/imports/** : Call plugin's sync endpoint `/api/plugins/librenms_plugin/jobs/{pk}/sync-status/` to update database after stopping RQ job

Applied to files:

  • netbox_librenms_plugin/import_utils/bulk_import.py
📚 Learning: 2026-02-15T15:39:03.405Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/background-jobs.instructions.md:0-0
Timestamp: 2026-02-15T15:39:03.405Z
Learning: Applies to **/jobs.py : Jobs must run via Redis Queue (RQ) in Redis, separate from the database Job model, and real-time status must be checked via RQ, not the database

Applied to files:

  • netbox_librenms_plugin/import_utils/bulk_import.py
📚 Learning: 2026-02-15T15:39:03.405Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/background-jobs.instructions.md:0-0
Timestamp: 2026-02-15T15:39:03.405Z
Learning: Applies to **/views/imports/** : Poll `/api/core/background-tasks/{uuid}/` for real-time RQ status instead of polling the database

Applied to files:

  • netbox_librenms_plugin/import_utils/bulk_import.py
📚 Learning: 2026-02-15T15:39:03.405Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/background-jobs.instructions.md:0-0
Timestamp: 2026-02-15T15:39:03.405Z
Learning: Applies to **/views/imports/** : Use Job UUID (`job.job_id`) for RQ API endpoints: `/api/core/background-tasks/{uuid}/`

Applied to files:

  • netbox_librenms_plugin/import_utils/bulk_import.py
📚 Learning: 2026-02-15T15:39:03.405Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/background-jobs.instructions.md:0-0
Timestamp: 2026-02-15T15:39:03.405Z
Learning: Applies to **/jobs.py : Database Job status values must be: `pending`, `scheduled`, `running`, `completed`, `failed`, `errored` (NO `cancelled` status exists)

Applied to files:

  • netbox_librenms_plugin/import_utils/bulk_import.py
📚 Learning: 2026-02-15T15:39:03.405Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/background-jobs.instructions.md:0-0
Timestamp: 2026-02-15T15:39:03.405Z
Learning: Applies to **/jobs.py : RQ status values must be: `queued`, `started`, `finished`, `stopped`, `failed` (NOT `completed`)

Applied to files:

  • netbox_librenms_plugin/import_utils/bulk_import.py
📚 Learning: 2026-02-15T15:38:54.189Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-02-15T15:38:54.189Z
Learning: Applies to **/*.py : Matching must be exact-only for site, platform, device type, and role; do not add fuzzy matching; use the functions `find_matching_site`, `match_librenms_hardware_to_device_type`, and `find_matching_platform` from `utils.py`

Applied to files:

  • netbox_librenms_plugin/views/imports/actions.py
📚 Learning: 2026-02-15T15:39:11.578Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/frontend.instructions.md:0-0
Timestamp: 2026-02-15T15:39:11.578Z
Learning: Applies to netbox_librenms_plugin/templates/**/*.html : Avoid `outerHTML` swaps in HTMX; use OOB or targeted `innerHTML` swaps to keep table layout intact.

Applied to files:

  • netbox_librenms_plugin/views/imports/actions.py
📚 Learning: 2026-02-15T15:39:11.578Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/frontend.instructions.md:0-0
Timestamp: 2026-02-15T15:39:11.578Z
Learning: Applies to netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/**/*.html : HTMX fragments should live in `templates/netbox_librenms_plugin/htmx/`. Keep server responses and HTMX targets in sync.

Applied to files:

  • netbox_librenms_plugin/views/imports/actions.py
📚 Learning: 2026-02-15T15:39:11.578Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/frontend.instructions.md:0-0
Timestamp: 2026-02-15T15:39:11.578Z
Learning: Applies to netbox_librenms_plugin/templates/**/*.html : HTMX 2.x is the primary async layer. Table row updates should return `<tr hx-swap-oob="true">`.

Applied to files:

  • netbox_librenms_plugin/views/imports/actions.py
📚 Learning: 2026-02-15T15:39:11.578Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/instructions/frontend.instructions.md:0-0
Timestamp: 2026-02-15T15:39:11.578Z
Learning: Applies to netbox_librenms_plugin/templates/**/*.html : Keep `<select class="device-role-select">` markup stable to preserve JavaScript hook-up with TomSelect decorators.

Applied to files:

  • netbox_librenms_plugin/views/imports/actions.py
📚 Learning: 2026-02-15T15:38:54.189Z
Learnt from: CR
Repo: marcinpsk/netbox-librenms-plugin PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-02-15T15:38:54.189Z
Learning: Applies to **/*.py : Reuse the `librenms_api.py` LibreNMS client instead of making new `requests` calls; it handles multi-server configs via `LibreNMSSettings` model and the `servers` plugin config, plus caching

Applied to files:

  • netbox_librenms_plugin/forms.py
🧬 Code graph analysis (3)
netbox_librenms_plugin/views/sync/device_fields.py (2)
netbox_librenms_plugin/utils.py (1)
  • match_librenms_hardware_to_device_type (302-367)
netbox_librenms_plugin/librenms_api.py (2)
  • get_librenms_id (173-231)
  • get_device_info (314-337)
netbox_librenms_plugin/tests/test_permissions.py (1)
netbox_librenms_plugin/views/sync/device_fields.py (8)
  • RemoveServerMappingView (389-456)
  • post (19-56)
  • post (66-110)
  • post (120-171)
  • post (181-238)
  • post (251-310)
  • post (320-386)
  • post (396-456)
netbox_librenms_plugin/import_utils/cache.py (1)
netbox_librenms_plugin/views/imports/list.py (1)
  • get (128-375)
🔇 Additional comments (9)
netbox_librenms_plugin/views/sync/device_fields.py (1)

281-290: Atomic create-and-assign flow is a solid reliability improvement.

Wrapping platform creation and device assignment in one transaction prevents partial writes in this path.

netbox_librenms_plugin/forms.py (5)

1-32: LGTM!

Import additions align with the new mapping form classes and their field requirements.


108-238: LGTM!

ServerConfigForm and ImportSettingsForm are well-structured with comprehensive validation for the VC member naming pattern.


397-397: Consider replacing deprecated NullBooleanField with an alternative field for forward compatibility.

NullBooleanField is deprecated in Django 3.1 and removed in Django 4.0+. Replace with TypedChoiceField or ChoiceField to future-proof the code.


456-611: LGTM!

SNMP forms properly delegate poller group population to the shared helper function, maintaining DRY principles.


780-781: LGTM!

Location cache is properly scoped by api.server_key, preventing cross-server label bleed.

netbox_librenms_plugin/import_utils/cache.py (1)

71-95: Cache keying and metadata hardening look solid.

The server-scoped location cache key and guarded cached_at handling materially improve multi-server correctness and resilience.

Also applies to: 129-164

netbox_librenms_plugin/views/imports/list.py (1)

95-116: Good propagation and observability improvements.

The naming-preference propagation into cache-key paths plus exc_info=True on cache pre-check failures improves consistency and debuggability.

Also applies to: 243-245, 443-467, 478-495

netbox_librenms_plugin/tests/test_permissions.py (1)

1008-1009: The lambda approach for __enter__ / __exit__ mocking works correctly and does not raise TypeError. Both the original code and the proposed alternative approach function properly with Python's context manager protocol.

Likely an incorrect or invalid review comment.

Comment on lines +74 to 85
try:
api = LibreNMSAPI()
server_id = api.librenms_url.rstrip("/")
cache_key = f"librenms_poller_group_choices_{server_id}"
except Exception:
cache_key = "librenms_poller_group_choices"
cached_choices = cache.get(cache_key)
if cached_choices:
return cached_choices

try:
api = LibreNMSAPI()

ghost Mar 3, 2026

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

Redundant LibreNMSAPI instantiation.

The first api instance created at line 75 is only used to derive the cache key, then discarded. A second instance is created at line 85 for the actual API call. Reuse the first instance to avoid the overhead.

♻️ Proposed fix
     choices = [("0", "Default (0)")]

     try:
         api = LibreNMSAPI()
         server_id = api.librenms_url.rstrip("/")
         cache_key = f"librenms_poller_group_choices_{server_id}"
     except Exception:
+        api = None
         cache_key = "librenms_poller_group_choices"
     cached_choices = cache.get(cache_key)
     if cached_choices:
         return cached_choices

     try:
-        api = LibreNMSAPI()
+        if api is None:
+            api = LibreNMSAPI()
         success, poller_groups = api.get_poller_groups()
🤖 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 74 - 85, The code creates
LibreNMSAPI() twice—once to build cache_key from api.librenms_url and again for
the real API call; reuse the first instance instead: instantiate LibreNMSAPI()
once into api, compute server_id and cache_key from api.librenms_url, check
cache, and then use the same api for subsequent API calls (referencing
LibreNMSAPI, api, cache_key, and librenms_url) to avoid redundant instantiation
and overhead.

Comment thread netbox_librenms_plugin/forms.py
Comment thread netbox_librenms_plugin/import_utils/bulk_import.py
Comment thread netbox_librenms_plugin/tests/test_permissions.py Outdated
Comment thread netbox_librenms_plugin/views/imports/actions.py Outdated
Comment on lines +1135 to +1145
with transaction.atomic():
try:
locked_device = Device.objects.select_for_update().get(pk=existing_device.pk)
except Device.DoesNotExist:
return HttpResponse(
"Device no longer exists; it may have been deleted concurrently.",
status=409,
)
# Re-check for serial ownership conflict under lock
conflict_device = Device.objects.filter(serial=incoming_serial).exclude(pk=locked_device.pk).first()
if conflict_device:

ghost Mar 3, 2026

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

sync_serial conflict check is not lock-consistent with the transactional intent.

Line 1144 checks for existing serial ownership without select_for_update(). Two concurrent syncs on different devices can still both pass the check and write the same serial. This branch should mirror the locked conflict read used in update/update_serial.

🔒 Proposed fix
-                    conflict_device = Device.objects.filter(serial=incoming_serial).exclude(pk=locked_device.pk).first()
+                    conflict_device = (
+                        Device.objects.select_for_update()
+                        .filter(serial=incoming_serial)
+                        .exclude(pk=locked_device.pk)
+                        .first()
+                    )

For full enforcement, consider a DB-level unique constraint on Device.serial if business rules allow it.

🤖 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 1135 - 1145,
The serial-conflict check in sync_serial is not lock-consistent: after locking
existing_device into locked_device you call
Device.objects.filter(serial=incoming_serial).exclude(pk=locked_device.pk).first()
without acquiring a row lock, allowing a race where two concurrent syncs both
pass and write the same serial. Change that query to acquire the same FOR UPDATE
lock (e.g., use .select_for_update() on the queryset) so the conflict_device
lookup is performed under the transaction lock, referencing the same variables
(locked_device, incoming_serial) and keeping the logic consistent with the
earlier locked read; optionally note adding a DB unique constraint on
Device.serial for full enforcement.

Comment thread netbox_librenms_plugin/views/sync/device_fields.py
Comment on lines +389 to +390
class RemoveServerMappingView(LibreNMSPermissionMixin, NetBoxObjectPermissionMixin, View):
"""Remove a single server entry from the device's librenms_id custom field dict."""

ghost Mar 3, 2026

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

Use LibreNMS API helper for mapping retrieval instead of direct custom field reads.

Line 407 and Line 435 directly read custom_field_data["librenms_id"] to retrieve mapping state. This bypasses the project’s mapping access contract and can drift from centralized retrieval behavior.

As per coding guidelines: "Always call LibreNMSAPI.get_librenms_id to retrieve the device/VM LibreNMS mapping via the librenms_id custom field instead of touching the field directly".

Also applies to: 407-409, 435-435

🤖 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 389 - 390,
The code in RemoveServerMappingView directly accesses
custom_field_data["librenms_id"]; replace those direct reads with the
centralized helper LibreNMSAPI.get_librenms_id(...) so retrieval honors the
project’s mapping access contract — update both occurrences referenced (the
retrieval around the validation/lookup prior to removal and the subsequent check
before modifying the mapping) to call LibreNMSAPI.get_librenms_id with the
Device/VM instance, handle a None/absent return the same way the current logic
expects, and then proceed to update/delete the mapping using the existing update
flow.

Comment on lines +418 to +421
plugins_cfg = django_settings.PLUGINS_CONFIG.get("netbox_librenms_plugin", {})
configured_servers = plugins_cfg.get("servers", {})
legacy_url_configured = bool(plugins_cfg.get("librenms_url"))
if server_key in configured_servers or (legacy_url_configured and server_key == "default"):

ghost Mar 3, 2026

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

Harden configured-server checks against malformed plugin config.

At Line 421 and Line 438, server_key in configured_servers assumes servers is a dict/iterable. If servers is None or another invalid type, this can raise and return a 500 from this endpoint.

Suggested fix
-        configured_servers = plugins_cfg.get("servers", {})
+        configured_servers_raw = plugins_cfg.get("servers", {})
+        configured_servers = configured_servers_raw if isinstance(configured_servers_raw, dict) else {}
         legacy_url_configured = bool(plugins_cfg.get("librenms_url"))
         if server_key in configured_servers or (legacy_url_configured and server_key == "default"):
             messages.error(
                 request,
                 f"Cannot remove mapping for configured server '{server_key}'. "
                 "Remove the server from plugin configuration first, then retry.",
             )
             return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk)
@@
-            _is_protected = server_key in configured_servers or (legacy_url_configured and server_key == "default")
+            _is_protected = server_key in configured_servers or (legacy_url_configured and server_key == "default")

Also applies to: 437-438

🤖 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 418 - 421,
The code assumes plugins_cfg.get("servers") is iterable which can raise if it's
None or malformed; update the check around plugins_cfg/configured_servers in the
device_fields logic (where plugins_cfg, configured_servers,
legacy_url_configured and server_key are used) to first coerce or validate
configured_servers (e.g. set configured_servers = plugins_cfg.get("servers") or
{} or verify isinstance(configured_servers, (dict, list, set, tuple))) and then
perform membership testing only on validated iterables (e.g. check server_key in
configured_servers when it's a dict/list/etc.), preserving the existing
legacy_url_configured && server_key == "default" branch.

ghost 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 (5)
netbox_librenms_plugin/views/sync/device_fields.py (2)

51-55: ⚠️ Potential issue | 🟠 Major

Do not return raw exception details in user-facing messages.

These branches expose validation/runtime internals directly to end users. Keep UI messages generic and log full exceptions server-side.

🔒 Proposed fix pattern
-        except (ValidationError, IntegrityError) as e:
+        except (ValidationError, IntegrityError):
             device.name = old_name
-            error_msg = e.message_dict if hasattr(e, "message_dict") else str(e)
-            messages.error(request, f"Failed to update device name to '{sys_name}': {error_msg}")
+            logger.exception("Failed to update device name to '%s' for device pk=%s", sys_name, pk)
+            messages.error(request, f"Failed to update device name to '{sys_name}'.")
             return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk)
@@
-                except ValidationError as exc:
+                except ValidationError:
                     transaction.set_rollback(True)
-                    messages.error(request, f"Validation error removing mapping: {exc}")
+                    logger.exception("Validation error removing mapping for server '%s' on device pk=%s", server_key, pk)
+                    messages.error(request, "Validation error removing mapping.")
                     return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk)
-                except Exception as exc:
+                except Exception:
                     transaction.set_rollback(True)
-                    messages.error(request, f"Error removing mapping for server '{server_key}': {exc}")
+                    logger.exception("Error removing mapping for server '%s' on device pk=%s", server_key, pk)
+                    messages.error(request, f"Error removing mapping for server '{server_key}'.")
                     return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk)

Also applies to: 460-467

🤖 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 51 - 55,
Replace the current user-facing error that includes raw exception details in the
except block handling ValidationError/IntegrityError (the clause starting with
"except (ValidationError, IntegrityError) as e" and the subsequent
messages.error(...) call) with a generic friendly message; instead, log the full
exception server-side (using the module logger or request logger) including the
exception object and stacktrace, keep device.name = old_name and the redirect to
"plugins:netbox_librenms_plugin:device_librenms_sync", and update the
messages.error text to a generic string like "Failed to update device name."
Apply the same change pattern to the other similar branch mentioned in the
review (duplicate block).

435-437: ⚠️ Potential issue | 🟠 Major

Validate servers config type before membership checks.

server_key in configured_servers will raise if servers is None or malformed, causing a 500 in this POST path.

🔧 Proposed fix
-        configured_servers = plugins_cfg.get("servers", {})
+        configured_servers_raw = plugins_cfg.get("servers", {})
+        configured_servers = configured_servers_raw if isinstance(configured_servers_raw, dict) else {}
         legacy_url_configured = bool(plugins_cfg.get("librenms_url"))
         if server_key in configured_servers or (legacy_url_configured and server_key == "default"):
@@
-            _is_protected = server_key in configured_servers or (legacy_url_configured and server_key == "default")
+            _is_protected = server_key in configured_servers or (legacy_url_configured and server_key == "default")

Also applies to: 453-454

🤖 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 435 - 437,
The membership check uses configured_servers = plugins_cfg.get("servers", {})
but if plugins_cfg["servers"] is None or not an iterable/mapping the expression
server_key in configured_servers will raise; update the code around
configured_servers, plugins_cfg and server_key to validate/normalize the servers
value before using "in" (e.g. ensure configured_servers is a dict or list by
checking isinstance(configured_servers, (dict, list, set)) and falling back to
{} or []), then perform the server_key membership check; apply the same
validation/normalization to the other occurrence around lines 453-454 where
configured_servers is used.
netbox_librenms_plugin/import_utils/virtual_chassis.py (1)

309-317: ⚠️ Potential issue | 🟠 Major

Fix 1-based position handling in update_vc_member_suggested_names().

This block adds +1 to positions that are already 1-based and can persist 0 back to member["position"] on fallback, producing incorrect suggested names and invalid VC positions.

🔧 Proposed fix
-    for idx, member in enumerate(vc_data.get("members", [])):
-        raw_position = member.get("position", idx)
+    for idx, member in enumerate(vc_data.get("members", [])):
+        raw_position = member.get("position", idx + 1)
         try:
             base_position = int(raw_position)
         except (TypeError, ValueError):
-            base_position = idx
-        position = base_position + 1  # Convert to 1-based position
-        member["position"] = base_position
+            base_position = idx + 1
+        if base_position < 1:
+            base_position = idx + 1
+        position = base_position
+        member["position"] = position
         member["suggested_name"] = _generate_vc_member_name(
             master_name, position, serial=member.get("serial"), pattern=vc_pattern
         )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@netbox_librenms_plugin/import_utils/virtual_chassis.py` around lines 309 -
317, The code currently mis-handles 1-based vs 0-based positions: parse
raw_position into an int, then if parsed > 0 treat that as a 1-based value and
set base_index = parsed - 1, otherwise fall back to base_index = idx; compute
position = base_index + 1 (1-based) and set member["position"] = position (not
base_index), then pass position into _generate_vc_member_name; update the block
using vc_data, member, raw_position, base_position (rename to base_index if you
like), position and _generate_vc_member_name accordingly so we never persist a 0
and correctly generate suggested names.
netbox_librenms_plugin/forms.py (1)

74-85: 🧹 Nitpick | 🔵 Trivial

Reuse the first LibreNMSAPI instance in _get_librenms_poller_group_choices().

The helper currently creates one client for keying and another for retrieval. Reusing one instance is cleaner and avoids unnecessary init work.

♻️ Proposed refactor
-    try:
-        api = LibreNMSAPI()
-        server_id = api.librenms_url.rstrip("/")
-        cache_key = f"librenms_poller_group_choices_{server_id}"
-    except Exception:
-        cache_key = "librenms_poller_group_choices"
+    api = None
+    try:
+        api = LibreNMSAPI()
+        server_id = api.librenms_url.rstrip("/")
+        cache_key = f"librenms_poller_group_choices_{server_id}"
+    except Exception:
+        cache_key = "librenms_poller_group_choices"
@@
-    try:
-        api = LibreNMSAPI()
+    try:
+        if api is None:
+            api = LibreNMSAPI()
         success, poller_groups = api.get_poller_groups()
🤖 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 74 - 85, In
_get_librenms_poller_group_choices(), remove the duplicate LibreNMSAPI()
instantiation and reuse the first 'api' instance: initialize api = LibreNMSAPI()
in the initial try, derive server_id and cache_key from api.librenms_url, check
the cache with that cache_key, and then call the API retrieval on the same 'api'
object; if the initial creation failed, set api = None in the except and only
instantiate LibreNMSAPI() right before retrieval when api is None. Reference
symbols: _get_librenms_poller_group_choices, LibreNMSAPI, api, cache_key.
netbox_librenms_plugin/views/imports/actions.py (1)

1148-1149: ⚠️ Potential issue | 🟠 Major

Lock the sync_serial conflict lookup to match transactional intent.

Line 1148 still performs an unlocked read (Device.objects.filter(...).first()) inside the serial sync transaction. Two concurrent requests can still pass this check and race into duplicate serial assignment.

🔧 Proposed fix
-                    conflict_device = Device.objects.filter(serial=incoming_serial).exclude(pk=locked_device.pk).first()
+                    conflict_device = (
+                        Device.objects.select_for_update()
+                        .filter(serial=incoming_serial)
+                        .exclude(pk=locked_device.pk)
+                        .first()
+                    )

Run this to verify the current query is unlocked and confirm the fix:

#!/bin/bash
set -euo pipefail

FILE="netbox_librenms_plugin/views/imports/actions.py"

echo "Locate sync_serial branch:"
rg -n 'elif action == "sync_serial"' "$FILE" -A30 -B5

echo
echo "Locate conflict query inside sync_serial:"
rg -n 'conflict_device\s*=\s*Device\.objects\..*serial=incoming_serial' "$FILE" -A4 -B2

echo
echo "Expected after fix: query chain contains select_for_update() before filter(serial=incoming_serial)."
🤖 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 1148 - 1149,
The conflict lookup in the sync_serial branch uses an unlocked read
(conflict_device =
Device.objects.filter(serial=incoming_serial).exclude(pk=locked_device.pk).first())
which allows a race; modify the lookup to acquire the same row lock as the
surrounding transaction by using select_for_update() on the queryset before
filtering by serial (i.e. call
Device.objects.select_for_update().filter(serial=incoming_serial).exclude(pk=locked_device.pk).first()),
so the conflict_device query participates in the transaction lock and prevents
concurrent duplicate serial assignments.
🤖 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/bulk_import.py`:
- Around line 639-645: The code currently hardcodes the cache index key format
("librenms_cache_index_{api.server_key}"); replace that with the shared cache
helper so keys stay consistent across flows: call
get_active_cached_searches(...) (passing the api.server_key or api object as
required by the helper) to obtain the cache index key instead of the f-string,
use get_cache_metadata_key(...) to produce/validate cache_metadata_key where
applicable, and then append and cache.set the index with api.cache_timeout as
before; ensure all references in process_device_filters() use
get_active_cached_searches, get_cache_metadata_key,
get_validated_device_cache_key or get_import_device_cache_key as appropriate
rather than hardcoded key strings.

---

Duplicate comments:
In `@netbox_librenms_plugin/forms.py`:
- Around line 74-85: In _get_librenms_poller_group_choices(), remove the
duplicate LibreNMSAPI() instantiation and reuse the first 'api' instance:
initialize api = LibreNMSAPI() in the initial try, derive server_id and
cache_key from api.librenms_url, check the cache with that cache_key, and then
call the API retrieval on the same 'api' object; if the initial creation failed,
set api = None in the except and only instantiate LibreNMSAPI() right before
retrieval when api is None. Reference symbols:
_get_librenms_poller_group_choices, LibreNMSAPI, api, cache_key.

In `@netbox_librenms_plugin/import_utils/virtual_chassis.py`:
- Around line 309-317: The code currently mis-handles 1-based vs 0-based
positions: parse raw_position into an int, then if parsed > 0 treat that as a
1-based value and set base_index = parsed - 1, otherwise fall back to base_index
= idx; compute position = base_index + 1 (1-based) and set member["position"] =
position (not base_index), then pass position into _generate_vc_member_name;
update the block using vc_data, member, raw_position, base_position (rename to
base_index if you like), position and _generate_vc_member_name accordingly so we
never persist a 0 and correctly generate suggested names.

In `@netbox_librenms_plugin/views/imports/actions.py`:
- Around line 1148-1149: The conflict lookup in the sync_serial branch uses an
unlocked read (conflict_device =
Device.objects.filter(serial=incoming_serial).exclude(pk=locked_device.pk).first())
which allows a race; modify the lookup to acquire the same row lock as the
surrounding transaction by using select_for_update() on the queryset before
filtering by serial (i.e. call
Device.objects.select_for_update().filter(serial=incoming_serial).exclude(pk=locked_device.pk).first()),
so the conflict_device query participates in the transaction lock and prevents
concurrent duplicate serial assignments.

In `@netbox_librenms_plugin/views/sync/device_fields.py`:
- Around line 51-55: Replace the current user-facing error that includes raw
exception details in the except block handling ValidationError/IntegrityError
(the clause starting with "except (ValidationError, IntegrityError) as e" and
the subsequent messages.error(...) call) with a generic friendly message;
instead, log the full exception server-side (using the module logger or request
logger) including the exception object and stacktrace, keep device.name =
old_name and the redirect to
"plugins:netbox_librenms_plugin:device_librenms_sync", and update the
messages.error text to a generic string like "Failed to update device name."
Apply the same change pattern to the other similar branch mentioned in the
review (duplicate block).
- Around line 435-437: The membership check uses configured_servers =
plugins_cfg.get("servers", {}) but if plugins_cfg["servers"] is None or not an
iterable/mapping the expression server_key in configured_servers will raise;
update the code around configured_servers, plugins_cfg and server_key to
validate/normalize the servers value before using "in" (e.g. ensure
configured_servers is a dict or list by checking isinstance(configured_servers,
(dict, list, set)) and falling back to {} or []), then perform the server_key
membership check; apply the same validation/normalization to the other
occurrence around lines 453-454 where configured_servers is used.

ℹ️ Review info

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

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 347938a and c846c5c.

📒 Files selected for processing (8)
  • netbox_librenms_plugin/forms.py
  • netbox_librenms_plugin/import_utils/bulk_import.py
  • netbox_librenms_plugin/import_utils/filters.py
  • netbox_librenms_plugin/import_utils/virtual_chassis.py
  • netbox_librenms_plugin/tests/test_import_utils.py
  • netbox_librenms_plugin/tests/test_permissions.py
  • netbox_librenms_plugin/views/imports/actions.py
  • netbox_librenms_plugin/views/sync/device_fields.py

Comment on lines +639 to +645
cache_index_key = f"librenms_cache_index_{api.server_key}"
cache_index = cache.get(cache_index_key, [])
# Add this cache key if not already in index
if cache_metadata_key not in cache_index:
cache_index.append(cache_metadata_key)
# Store index with same timeout as the metadata
cache.set(cache_index_key, cache_index, timeout=api.cache_timeout)

ghost Mar 3, 2026

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

Avoid hardcoded cache-index key formats in process_device_filters().

This section still constructs librenms_cache_index_* inline. It should use the shared cache helper contract to avoid key drift between sync/background flows and related list-view lookups.

Based on learnings: “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”.

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

In `@netbox_librenms_plugin/import_utils/bulk_import.py` around lines 639 - 645,
The code currently hardcodes the cache index key format
("librenms_cache_index_{api.server_key}"); replace that with the shared cache
helper so keys stay consistent across flows: call
get_active_cached_searches(...) (passing the api.server_key or api object as
required by the helper) to obtain the cache index key instead of the f-string,
use get_cache_metadata_key(...) to produce/validate cache_metadata_key where
applicable, and then append and cache.set the index with api.cache_timeout as
before; ensure all references in process_device_filters() use
get_active_cached_searches, get_cache_metadata_key,
get_validated_device_cache_key or get_import_device_cache_key as appropriate
rather than hardcoded key strings.

@marcinpsk marcinpsk closed this Mar 3, 2026
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