Skip to content
1 change: 1 addition & 0 deletions docs/development/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ The test suite covers all major plugin functionality. Tests are organized by the
| [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_init.py](../../netbox_librenms_plugin/tests/test_init.py) | Plugin startup—`_ensure_librenms_id_custom_field` creation, type migration, and multi-DB alias handling |
| [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 |
Expand Down
4 changes: 2 additions & 2 deletions docs/usage_tips/custom_field.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +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.

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

!!! 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.
On 0.4.3+, rerun migrations first (`manage.py migrate`). If you need to recreate the field manually on current releases, use the JSON schema below. Pre-0.4.2 releases used an Integer field — do not use Integer for new entries.

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

Expand Down
40 changes: 26 additions & 14 deletions netbox_librenms_plugin/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,25 +70,28 @@ def _validate_legacy_config(self, plugin_config):

def _ensure_librenms_id_custom_field(sender, **kwargs):
"""
Auto-create the 'librenms_id' custom field if it doesn't exist.
Auto-create (or migrate) the 'librenms_id' custom field.
Runs after migrations via post_migrate signal to ensure tables exist.
Uses dispatch_uid to avoid duplicate connections.

librenms_id stores a per-server JSON mapping {"server_key": device_id}.
Legacy installations may have this field typed as 'integer'; we upgrade it
to 'json' automatically so the UI and API accept the dict format.
"""
# Only run once per migrate invocation (post_migrate fires per-app).
# The _executed flag is intentionally never reset: migrations are expected to
# run in short-lived CLI processes (manage.py migrate) where the flag is
# naturally cleared on exit. Long-running processes (e.g. gunicorn workers)
# should not rely on this handler re-executing after startup.
if getattr(_ensure_librenms_id_custom_field, "_executed", False):
# Track per-alias execution so each database alias is bootstrapped exactly once.
db_alias = kwargs.get("using") or "default"
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

import logging

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={
"type": "json",
Expand All @@ -101,6 +104,15 @@ def _ensure_librenms_id_custom_field(sender, **kwargs):
},
)

# Migrate legacy integer-typed field to JSON so the multi-server
# dict format {"server_key": device_id} is accepted by the UI/API.
if not created and cf.type == "integer":
cf.type = "json"
cf.save(using=db_alias, update_fields=["type"])
logging.getLogger("netbox_librenms_plugin").info(
"Migrated 'librenms_id' custom field type from integer to json"
)
Comment thread
marcinpsk marked this conversation as resolved.

# Ensure the field is assigned to the required object types
from dcim.models import Device, Interface
from virtualization.models import VirtualMachine, VMInterface
Expand All @@ -109,21 +121,21 @@ 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)

if created:
import logging

logging.getLogger("netbox_librenms_plugin").info(
"Auto-created 'librenms_id' custom field for Device, VirtualMachine, Interface, VMInterface"
)

# Mark this alias as executed after successful completion to allow retry on failure.
executed_aliases.add(db_alias)
_ensure_librenms_id_custom_field._executed_aliases = executed_aliases
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)


Expand Down
31 changes: 23 additions & 8 deletions netbox_librenms_plugin/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,12 @@
from netbox.api.viewsets import NetBoxModelViewSet
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import BasePermission, SAFE_METHODS
from rq.exceptions import NoSuchJobError
from rq.job import Job as RQJob

from netbox_librenms_plugin.constants import PERM_CHANGE_PLUGIN, PERM_VIEW_PLUGIN
from netbox_librenms_plugin.filters import InterfaceTypeMappingFilterSet
from netbox_librenms_plugin.jobs import FilterDevicesJob, ImportDevicesJob
from netbox_librenms_plugin.models import InterfaceTypeMapping

from .serializers import InterfaceTypeMappingSerializer
Expand All @@ -22,8 +25,8 @@ class LibreNMSPluginPermission(BasePermission):
"""
Permission class for LibreNMS plugin API endpoints.

- GET requests require view_librenmssettings
- All other requests require change_librenmssettings
- Safe requests (GET, HEAD, OPTIONS) require netbox_librenms_plugin.view_librenmssettings
- All other requests require netbox_librenms_plugin.change_librenmssettings
"""

def has_permission(self, request, view):
Expand All @@ -36,6 +39,7 @@ class InterfaceTypeMappingViewSet(NetBoxModelViewSet):
"""API viewset for InterfaceTypeMapping CRUD operations."""

permission_classes = [LibreNMSPluginPermission]
filterset_class = InterfaceTypeMappingFilterSet

queryset = InterfaceTypeMapping.objects.all()
serializer_class = InterfaceTypeMappingSerializer
Expand All @@ -50,15 +54,18 @@ def sync_job_status(request, job_pk):
This is needed because NetBox's worker doesn't always update the database
when a job is stopped before it starts processing.

Only allows users to sync their own LibreNMS jobs.

Args:
request: Django request
job_pk: Primary key of the Job to sync

Returns:
JsonResponse with updated status
"""
_LIBRENMS_JOB_NAMES = (FilterDevicesJob.Meta.name, ImportDevicesJob.Meta.name)
try:
job = Job.objects.get(pk=job_pk)
job = Job.objects.get(pk=job_pk, user=request.user, name__in=_LIBRENMS_JOB_NAMES)
except Job.DoesNotExist:
return JsonResponse({"error": "Job not found"}, status=404)

Expand All @@ -74,18 +81,26 @@ def sync_job_status(request, job_pk):
if not job.completed:
job.completed = timezone.now()
job.save(update_fields=["status", "completed"])
logger.info(f"Synced Job #{job.pk}: DB status updated to failed (RQ: {rq_status})")
logger.info("Synced Job #%s: DB status updated to failed (RQ: %s)", job.pk, rq_status)
return JsonResponse({"status": "updated", "db_status": job.status, "rq_status": rq_status})
else:
# Job still active in RQ
return JsonResponse({"status": "no_change", "db_status": job.status, "rq_status": rq_status})
except Exception as e:
# Job not in RQ queue - mark as failed
logger.warning(f"Job #{job.pk} not found in RQ: {e}")
if job.status == JobStatusChoices.STATUS_RUNNING:
except NoSuchJobError:
# Job not in RQ queue — mark any non-terminal DB job as failed
logger.warning("Job #%s not found in RQ (NoSuchJobError)", job.pk)
terminal_states = {
JobStatusChoices.STATUS_COMPLETED,
JobStatusChoices.STATUS_FAILED,
JobStatusChoices.STATUS_ERRORED,
}
if job.status not in terminal_states:
job.status = JobStatusChoices.STATUS_FAILED
if not job.completed:
job.completed = timezone.now()
job.save(update_fields=["status", "completed"])
return JsonResponse({"status": "updated", "db_status": job.status, "rq_status": "not_found"})
return JsonResponse({"status": "no_change", "db_status": job.status, "rq_status": "not_found"})
except Exception as e:
logger.exception("Unexpected error fetching RQ job for Job #%s: %s", job.pk, e)
return JsonResponse({"error": "Failed to fetch RQ job status"}, status=500)
Loading
Loading