Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
0844143
feat: auto-create librenms_id custom field via post_migrate signal
marcinpsk Feb 17, 2026
8a893ca
feat(multi-server): JSON custom field librenms_id with server managem…
marcinpsk Mar 12, 2026
22d08b5
fix(multi-server): CR review fixes, production hardening, and test co…
marcinpsk Mar 12, 2026
2969704
docs: add all coverage test files to testing guide; tighten VC mock c…
marcinpsk Mar 12, 2026
1eb0e4d
fix: use _norm_serial for master serial skip-check; drop over-require…
marcinpsk Mar 12, 2026
dcc25d8
fix: correctly mark missing role on refresh; restrict inventory mock …
marcinpsk Mar 12, 2026
cb9837a
fix(utils): surface DeviceType ambiguity instead of silently picking …
marcinpsk Mar 12, 2026
1016c2e
fix(review): address PR #25 findings — VM name-sync, port validation,…
marcinpsk Mar 12, 2026
b6b1ce1
fix(tests): apply deferred PR #25 test improvements — cable server_ke…
marcinpsk Mar 12, 2026
887929a
fix(review): address deferred PR #246 findings — vc_domain hash stabi…
marcinpsk Mar 12, 2026
e18d942
test(cables): mock ContentType in cable sync tests; remove spurious d…
marcinpsk Mar 12, 2026
b76af58
fix(cables): correct VC member interface resolution, fix cable tests
marcinpsk Mar 12, 2026
8c39991
fix(pr25-cr): address CodeRabbit review findings
marcinpsk Mar 12, 2026
f156c33
fix(tests): add module-level patch/MagicMock imports to test_view_wiring
marcinpsk Mar 12, 2026
f1e4ce5
fix(pr25-cr): address lost CR review findings
marcinpsk Mar 12, 2026
bff2a12
fix(pr25-cr): address latest CodeRabbit review findings
marcinpsk Mar 12, 2026
8a28ddb
fix(pr25-cr): address new CodeRabbit findings — ValueError handlers, …
marcinpsk Mar 12, 2026
37aebe3
fix(pr25-cr): use _build_filter_hash in get_import_search_cache_key, …
marcinpsk Mar 12, 2026
c060308
chore: restore .devcontainer/README.md to develop version
marcinpsk Mar 13, 2026
b125d22
fix(pr25-cr): dedup guard order, remove duplicate ID write, validate …
marcinpsk Mar 13, 2026
ead5a5c
fix(pr25-cr): fix data.get message None fallback, extract _normalize_…
marcinpsk Mar 13, 2026
45a527a
fix: CR findings — bulk_import refreshed path, librenms_api message f…
marcinpsk Mar 13, 2026
c96d61e
fix: create librenms_id custom field as JSON type for multi-server su…
marcinpsk Mar 18, 2026
823fa5b
Merge branch 'develop' into feature/auto-create-librenms-id
marcinpsk Mar 18, 2026
0251e58
Merge branch 'develop' into feature/auto-create-librenms-id
marcinpsk Mar 23, 2026
147430d
Merge branch 'develop' into pr/librems-id-multi-fixes
marcinpsk Mar 23, 2026
dc80d1b
tests: expand coverage with new test files and reorganize existing tests
marcinpsk Mar 23, 2026
1973476
Merge pull request #221 from marcinpsk/feature/auto-create-librenms-id
bonzo81 Mar 24, 2026
7c274bf
fix: address review findings — docstring wording, brittle source-pars…
marcinpsk Mar 24, 2026
ddf03c5
Merge pull request #253 from marcinpsk/pr/246-split-tests
bonzo81 Mar 25, 2026
6c09b39
Merge branch 'develop' into pr/librems-id-multi-fixes
marcinpsk Mar 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion docs/development/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,24 @@ The test suite covers all major plugin functionality. Tests are organized by the
| [test_coverage_device_fields.py](../../netbox_librenms_plugin/tests/test_coverage_device_fields.py) | Device field sync view—field update logic and device field mapping |
| [test_coverage_list.py](../../netbox_librenms_plugin/tests/test_coverage_list.py) | Import list view—background job decision, job result loading, and GET handler |
| [test_coverage_api.py](../../netbox_librenms_plugin/tests/test_coverage_api.py) | LibreNMS API client—malformed payload guards, error paths, and edge cases |
| [test_coverage_api2.py](../../netbox_librenms_plugin/tests/test_coverage_api2.py) | API views—device status, background job management, VM status endpoints |
| [test_coverage_base_views.py](../../netbox_librenms_plugin/tests/test_coverage_base_views.py) | Base view coverage tests—sync table views, context data, and data pipeline |
| [test_coverage_base_views2.py](../../netbox_librenms_plugin/tests/test_coverage_base_views2.py) | Additional base view coverage—IP address sync, cable matching, edge cases |
| [test_coverage_cache.py](../../netbox_librenms_plugin/tests/test_coverage_cache.py) | Import cache helpers—cache key generation, active search tracking, metadata |
| [test_coverage_device_operations.py](../../netbox_librenms_plugin/tests/test_coverage_device_operations.py) | Device validation—type matching, serial handling, VC detection, role lookup |
| [test_coverage_forms.py](../../netbox_librenms_plugin/tests/test_coverage_forms.py) | Import forms—filter form choices, background-job option guards, field validation |
| [test_coverage_mixins.py](../../netbox_librenms_plugin/tests/test_coverage_mixins.py) | View mixins—VLAN group scope resolution, VlanAssignmentMixin, scope priority |
| [test_coverage_sync_interfaces.py](../../netbox_librenms_plugin/tests/test_coverage_sync_interfaces.py) | Interface sync view—port caching, attribute updates, MAC handling, VC member routing |
| [test_coverage_sync_view.py](../../netbox_librenms_plugin/tests/test_coverage_sync_view.py) | Sync view base class—context preparation and tab rendering |
| [test_coverage_sync_views.py](../../netbox_librenms_plugin/tests/test_coverage_sync_views.py) | Sync action views—cables, IP addresses, VLAN sync action handlers |
| [test_coverage_sync_views2.py](../../netbox_librenms_plugin/tests/test_coverage_sync_views2.py) | Additional sync action view coverage—device fields, device name/type sync |
| [test_coverage_sync_views3.py](../../netbox_librenms_plugin/tests/test_coverage_sync_views3.py) | Further sync action view coverage—location sync, VLAN assignment edge cases |
| [test_coverage_actions.py](../../netbox_librenms_plugin/tests/test_coverage_actions.py) | Import action views—bulk import, device role/cluster/rack update, validation details |
| [test_coverage_filters.py](../../netbox_librenms_plugin/tests/test_coverage_filters.py) | Import filter logic—filter form processing and device count helpers |
| [test_coverage_tables.py](../../netbox_librenms_plugin/tests/test_coverage_tables.py) | Sync tables—column rendering, row data, interface and cable table helpers |
| [test_coverage_utils.py](../../netbox_librenms_plugin/tests/test_coverage_utils.py) | Utility function coverage—name matching, speed conversion, site/platform lookup |
| [test_coverage_virtual_chassis.py](../../netbox_librenms_plugin/tests/test_coverage_virtual_chassis.py) | Virtual chassis coverage—VC creation, position conflict handling, member naming |
| [test_coverage_vlans_table.py](../../netbox_librenms_plugin/tests/test_coverage_vlans_table.py) | VLAN sync table—column rendering, group assignment, VLAN comparison rows |
| [test_sync_modules.py](../../netbox_librenms_plugin/tests/test_sync_modules.py) | Module sync—inventory matching, module type resolution, and normalization rules |
| [test_modules_view.py](../../netbox_librenms_plugin/tests/test_modules_view.py) | Module sync view—context preparation, table rendering, and module bay mapping |
| [test_tables_modules.py](../../netbox_librenms_plugin/tests/test_tables_modules.py) | Module tables—column rendering, row formatting, and action buttons |
Expand Down Expand Up @@ -76,7 +92,7 @@ pytest netbox_librenms_plugin/tests/test_librenms_api.py::TestLibreNMSAPIConnect

```bash
# API client tests
pytest netbox_librenms_plugin/tests/test_librenms_api.py -v
pytest netbox_librenms_plugin/tests/test_librenms_api.py netbox_librenms_plugin/tests/test_coverage_api.py netbox_librenms_plugin/tests/test_coverage_api2.py -v

# Import and validation tests
pytest netbox_librenms_plugin/tests/test_import_utils.py netbox_librenms_plugin/tests/test_import_validation_helpers.py netbox_librenms_plugin/tests/test_utils.py -v
Expand Down
8 changes: 7 additions & 1 deletion docs/usage_tips/custom_field.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@

To enhance device identification and synchronization between NetBox and LibreNMS, this plugin supports using a custom field `librenms_id` on Device, Virtual Machine and Interface objects. While the plugin works without it, using this custom field is recommended for LibreNMS API lookups, and to assist with matching the remote device and remote interfaces for cable creation in Netbox. It can also be entered manually if no primary IP or FQDN is available.

!!! info "Automatic Creation"
As of version 0.4.2, the plugin **automatically creates** the `librenms_id` custom field when migrations are run. You no longer need to create it manually. The field is created for Device, Virtual Machine, Interface, and VM Interface objects.

For the Device and Virtual Machine objects the plugin will automatically populate the LibreNMS ID custom field when opening the LibreNMS Sync page if the device has been found in LibreNMS.

For the Interface object, the plugin will automatically populate the LibreNMS ID custom field when the interface data is synced from LibreNMS.
Expand All @@ -15,7 +18,10 @@ For the Interface object, the plugin will automatically populate the LibreNMS ID
- **Efficient Synchronization:** Enhances the reliability of API lookups.
- **Cable creation:** Allows better device identification for the creation of cables between NetBox devices.

## Suggested Custom Field Setup
## Manual Custom Field Setup (Legacy)

!!! note
This section is only needed if you are running an older version of the plugin that does not auto-create the field, or if you need to recreate it after deletion.

Follow these steps to create the `librenms_id` custom field in NetBox:

Expand Down
66 changes: 66 additions & 0 deletions netbox_librenms_plugin/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ def ready(self):
super().ready()

from django.conf import settings
from django.db.models.signals import post_migrate

plugin_config = getattr(settings, "PLUGINS_CONFIG", {}).get(self.name, {})

Expand All @@ -37,6 +38,12 @@ def ready(self):
else:
self._validate_legacy_config(plugin_config)

# Auto-create the librenms_id custom field after migrations complete
post_migrate.connect(
_ensure_librenms_id_custom_field,
dispatch_uid="netbox_librenms_plugin_ensure_cf",
)

def _validate_multi_server_config(self, servers_config):
"""Validate multi-server configuration."""
if not servers_config or not isinstance(servers_config, dict):
Expand All @@ -61,4 +68,63 @@ def _validate_legacy_config(self, plugin_config):
)


def _ensure_librenms_id_custom_field(sender, **kwargs):
"""
Auto-create the 'librenms_id' custom field if it doesn't exist.
Runs after migrations via post_migrate signal to ensure tables exist.
Uses dispatch_uid to avoid duplicate connections.
"""
# Only run once per migrate invocation (post_migrate fires per-app).
# The _executed flag is intentionally never reset: migrations are expected to
# run in short-lived CLI processes (manage.py migrate) where the flag is
# naturally cleared on exit. Long-running processes (e.g. gunicorn workers)
# should not rely on this handler re-executing after startup.
if getattr(_ensure_librenms_id_custom_field, "_executed", False):
return
_ensure_librenms_id_custom_field._executed = True # not reset; see comment above

try:
from django.contrib.contenttypes.models import ContentType

from extras.models import CustomField

cf, created = CustomField.objects.get_or_create(
name="librenms_id",
defaults={
"type": "json",
"label": "LibreNMS ID",
"description": "LibreNMS Device ID for synchronization (auto-created by plugin)",
"required": False,
"ui_visible": "if-set",
"ui_editable": "yes",
"is_cloneable": False,
},
)

# Ensure the field is assigned to the required object types
from dcim.models import Device, Interface
from virtualization.models import VirtualMachine, VMInterface

required_models = [Device, VirtualMachine, Interface, VMInterface]
current_types = set(cf.object_types.values_list("pk", flat=True))

for model in required_models:
ct = ContentType.objects.get_for_model(model)
if ct.pk not in current_types:
cf.object_types.add(ct)
Comment on lines +77 to +114

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:

#!/bin/bash
set -euo pipefail

FILE="$(fd "__init__.py" netbox_librenms_plugin | head -n1)"
echo "Inspecting: $FILE"

rg -n \
  "_ensure_librenms_id_custom_field|_executed|_executed_aliases|kwargs.get\\(\"using\"|CustomField\\.objects\\.using|ContentType\\.objects\\.db_manager|post_migrate\\.connect" \
  "$FILE"

Repository: marcinpsk/netbox-librenms-plugin

Length of output: 510


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="netbox_librenms_plugin/__init__.py"

# Read the full function
echo "=== Full _ensure_librenms_id_custom_field function ==="
sed -n '71,128p' "$FILE"

echo ""
echo "=== Search for any .using() or .db_manager() in the entire file ==="
rg -n "\.using\(|\.db_manager\(" "$FILE" || echo "No .using() or .db_manager() calls found"

Repository: marcinpsk/netbox-librenms-plugin

Length of output: 2723


Scope post-migrate custom-field work per DB alias.

Line 82 uses a single process-wide _executed flag, and lines 91 and 112 use default DB managers without .using(db_alias) scoping. In multi-DB migrations, this skips creation for later database aliases or targets the wrong database during migration.

🔧 Proposed fix
 def _ensure_librenms_id_custom_field(sender, **kwargs):
     """
     Auto-create the 'librenms_id' custom field if it doesn't exist.
     Runs after migrations via post_migrate signal to ensure tables exist.
     Uses dispatch_uid to avoid duplicate connections.
     """
-    # Only run once per migrate invocation (post_migrate fires per-app).
-    # The _executed flag is intentionally never reset: migrations are expected to
-    # run in short-lived CLI processes (manage.py migrate) where the flag is
-    # naturally cleared on exit.  Long-running processes (e.g. gunicorn workers)
-    # should not rely on this handler re-executing after startup.
-    if getattr(_ensure_librenms_id_custom_field, "_executed", False):
+    db_alias = kwargs.get("using", "default")
+
+    # Run once per DB alias per process (post_migrate fires per-app).
+    executed_aliases = getattr(_ensure_librenms_id_custom_field, "_executed_aliases", set())
+    if db_alias in executed_aliases:
         return
-    _ensure_librenms_id_custom_field._executed = True  # not reset; see comment above
+    executed_aliases.add(db_alias)
+    _ensure_librenms_id_custom_field._executed_aliases = executed_aliases
 
     try:
         from django.contrib.contenttypes.models import ContentType
 
         from extras.models import CustomField
 
-        cf, created = CustomField.objects.get_or_create(
+        cf, created = CustomField.objects.using(db_alias).get_or_create(
             name="librenms_id",
             defaults={
@@ -112,7 +119,7 @@ def _ensure_librenms_id_custom_field(sender, **kwargs):
         current_types = set(cf.object_types.values_list("pk", flat=True))
 
         for model in required_models:
-            ct = ContentType.objects.get_for_model(model)
+            ct = ContentType.objects.db_manager(db_alias).get_for_model(model)
             if ct.pk not in current_types:
                 cf.object_types.add(ct)
🤖 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 77 - 114, The handler
_ensure_librenms_id_custom_field currently uses a single process-wide _executed
flag and default managers, which breaks multi-DB migrations; change the guard to
track execution per DB (e.g., _executed_per_db dict keyed by db_alias passed
into the post_migrate handler) and use DB-scoped managers everywhere: call
CustomField.objects.using(db_alias).get_or_create(...), fetch ContentType via
ContentType.objects.using(db_alias).get_for_model(model) and add relations with
cf.object_types.using(db_alias).add(ct) (ensure cf is the instance loaded from
the same .using(db_alias) manager) so all creation and association run against
the intended database.


if created:
import logging

logging.getLogger("netbox_librenms_plugin").info(
"Auto-created 'librenms_id' custom field for Device, VirtualMachine, Interface, VMInterface"
)
except Exception as e:
# Don't break startup if custom field creation fails (e.g., during initial migration),
# but log the error so it's not silently swallowed.
import logging

logging.getLogger("netbox_librenms_plugin").exception("Failed to auto-create 'librenms_id' custom field: %s", e)


config = LibreNMSSyncConfig
85 changes: 44 additions & 41 deletions netbox_librenms_plugin/import_utils/bulk_import.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,13 +99,13 @@ def bulk_import_devices_shared(
if user is None and job is not None:
user = getattr(job.job, "user", None)

# Check permissions at start of bulk operation — both device and VM perms are
# Check permissions at start of bulk operation — device and VM add perms are
# required because any device may be flagged as import_as_vm during validation.
# change_device is needed for VC master/member updates; VMs are only created, not changed.
required_perms = [
"dcim.add_device",
"dcim.change_device",
"virtualization.add_virtualmachine",
"virtualization.change_virtualmachine",
]
require_permissions(user, required_perms, "import devices")

Expand Down Expand Up @@ -239,51 +239,50 @@ def bulk_import_devices_shared(
for m in vc_data.get("members", [])
)
if member_parts:
fingerprint = hashlib.md5((f"{device_id}," + ",".join(member_parts)).encode()).hexdigest()[
:12
]
fingerprint = hashlib.md5(",".join(member_parts).encode()).hexdigest()[:12]
vc_domain = f"librenms-stack-{fingerprint}"
else:
vc_domain = f"librenms-{device_id}"

# Guard VC creation with its own permission check — the upfront check
# only covers add_device/change_device; VirtualChassis needs a separate perm.
has_vc_perm, missing_vc_perms = check_user_permissions(user, ["dcim.add_virtualchassis"])
if not has_vc_perm:
warn_msg = (
f"Skipping VC creation for device {device_id}: "
f"missing permissions: {', '.join(missing_vc_perms)}"
)
if job and job.logger:
job.logger.warning(warn_msg)
else:
logger.warning(warn_msg)
# Only create VC if we haven't processed this stack yet
# Add to set BEFORE attempting creation to prevent race condition
elif vc_domain not in processed_vc_domains:
processed_vc_domains.add(vc_domain)
try:
vc = create_virtual_chassis_with_members(
result["device"],
vc_data["members"],
libre_device,
server_key=api.server_key,
if vc_domain not in processed_vc_domains:
# Guard VC creation with its own permission check — the upfront check
# only covers add_device/change_device; VirtualChassis needs a separate perm.
has_vc_perm, missing_vc_perms = check_user_permissions(user, ["dcim.add_virtualchassis"])
if not has_vc_perm:
warn_msg = (
f"Skipping VC creation for device {device_id}: "
f"missing permissions: {', '.join(missing_vc_perms)}"
)
vc_created_count += 1
log_msg = f"Created VC '{vc.name}' during bulk import for device {device_id}"
if job and job.logger:
job.logger.info(log_msg)
else:
logger.info(log_msg)
except Exception as vc_error:
# Remove from set on failure so retry is possible
processed_vc_domains.discard(vc_domain)
warn_msg = f"Failed to create VC for device {device_id}: {vc_error}"
if job and job.logger:
job.logger.warning(warn_msg)
else:
logger.warning(warn_msg)
# Don't fail the import, just log the warning
else:
# Add to set BEFORE attempting creation to prevent race condition
processed_vc_domains.add(vc_domain)
try:
vc = create_virtual_chassis_with_members(
result["device"],
vc_data["members"],
libre_device,
server_key=api.server_key,
)
vc_created_count += 1
log_msg = f"Created VC '{vc.name}' during bulk import for device {device_id}"
if job and job.logger:
job.logger.info(log_msg)
else:
logger.info(log_msg)
except Exception as vc_error:
# Remove from set on failure so retry is possible
processed_vc_domains.discard(vc_domain)
warn_msg = f"Failed to create VC for device {device_id}: {vc_error}"
if job and job.logger:
job.logger.warning(warn_msg)
else:
logger.warning(warn_msg)
# Don't fail the import, just log the warning

elif result.get("device"): # Device exists
skipped_list.append({"device_id": device_id, "reason": result["error"]})
Expand Down Expand Up @@ -386,8 +385,11 @@ def _refresh_existing_device(validation: dict, libre_device: dict = None, server
elif not validation.get("import_as_vm"):
validation["device_role"] = {"found": False, "role": None}
remove_validation_issue(validation, "role")
validation.setdefault("issues", []).append("Device role must be manually selected before import")
recalculate_validation_status(validation, is_vm=bool(validation.get("import_as_vm")))
# Re-assert non-importable state: recalculate bases can_import on
# issues alone, but an existing matched device must never be import-ready.
validation["can_import"] = False
validation["is_ready"] = False
return
else:
# Device was deleted since caching — recompute readiness to match
Expand All @@ -403,7 +405,7 @@ def _refresh_existing_device(validation: dict, libre_device: dict = None, server
except Exception as e:
existing_id = getattr(existing, "pk", "unknown") if existing else "none"
logger.error(f"Failed to refresh existing device (pk={existing_id}): {e}")
return
return

# existing_device was None at cache time — check if device was imported since
if not libre_device:
Expand Down Expand Up @@ -468,9 +470,10 @@ def _lookup_in_model(m):
actual_is_vm = found_as_cross_model != import_as_vm # XOR: cross flips the flag
validation["import_as_vm"] = actual_is_vm # Update so future refreshes query correct model
if not actual_is_vm and hasattr(new_device, "role") and new_device.role:
validation["device_role"] = {"found": True, "role": new_device.role}
apply_role_to_validation(validation, new_device.role, is_vm=False)
elif not actual_is_vm:
validation.setdefault("device_role", {}).update({"found": False, "role": None})
validation["device_role"] = {"found": False, "role": None}
recalculate_validation_status(validation, is_vm=actual_is_vm)
except Exception as e:
logger.error(f"Failed to check for newly imported device: {e}")

Expand Down
Loading
Loading