first commit
Some checks failed
ci / deploy (push) Has been cancelled
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled

This commit is contained in:
Vlastislav Svatek
2026-06-05 10:39:05 +02:00
commit 673e67106e
217 changed files with 76612 additions and 0 deletions

View File

@@ -0,0 +1,142 @@
from django.core.exceptions import ImproperlyConfigured
from netbox.plugins import PluginConfig
__author__ = "Andy Norwood"
__version__ = "0.4.6"
class LibreNMSSyncConfig(PluginConfig):
name = "netbox_librenms_plugin"
verbose_name = "NetBox Librenms Plugin"
description = "Netbox plugin to sync data between LibreNMS and Netbox."
author = __author__
version = __version__
base_url = "librenms_plugin"
min_version = "4.2.0"
required_settings = [] # Custom validation in ready() method
default_settings = {
"enable_caching": True,
"verify_ssl": True,
"interface_name_field": "ifName",
}
def ready(self):
"""
Perform custom validation for plugin configuration.
Supports both legacy single-server and new multi-server configurations.
"""
super().ready()
from django.conf import settings
from django.db.models.signals import post_migrate
plugin_config = getattr(settings, "PLUGINS_CONFIG", {}).get(self.name, {})
# Check if using new multi-server configuration
if "servers" in plugin_config:
self._validate_multi_server_config(plugin_config["servers"])
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):
raise ImproperlyConfigured(
f"Plugin {self.name} requires at least one server configuration in the 'servers' section."
)
for server_key, server_config in servers_config.items():
if not isinstance(server_config, dict):
raise ImproperlyConfigured(f"Plugin {self.name} server '{server_key}' must be a dictionary.")
for setting in ["librenms_url", "api_token"]:
if setting not in server_config:
raise ImproperlyConfigured(f"Plugin {self.name} server '{server_key}' requires '{setting}'.")
def _validate_legacy_config(self, plugin_config):
"""Validate legacy single-server configuration."""
for setting in ["librenms_url", "api_token"]:
if setting not in plugin_config:
raise ImproperlyConfigured(
f"Plugin {self.name} requires either 'servers' configuration or legacy '{setting}' setting."
)
def _ensure_librenms_id_custom_field(sender, **kwargs):
"""
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.
"""
# 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
import logging
try:
from django.contrib.contenttypes.models import ContentType
from extras.models import CustomField
cf, created = CustomField.objects.using(db_alias).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,
},
)
# 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"
)
# 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.db_manager(db_alias).get_for_model(model)
if ct.pk not in current_types:
cf.object_types.add(ct)
if created:
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.
logging.getLogger("netbox_librenms_plugin").exception("Failed to auto-create 'librenms_id' custom field: %s", e)
config = LibreNMSSyncConfig

View File

View File

View File

@@ -0,0 +1,13 @@
from netbox.api.serializers import NetBoxModelSerializer
from netbox_librenms_plugin.models import InterfaceTypeMapping
class InterfaceTypeMappingSerializer(NetBoxModelSerializer):
"""Serialize InterfaceTypeMapping model for REST API."""
class Meta:
"""Meta options for InterfaceTypeMappingSerializer."""
model = InterfaceTypeMapping
fields = ["id", "librenms_type", "librenms_speed", "netbox_type", "description"]

View File

@@ -0,0 +1,13 @@
from django.urls import path
from netbox.api.routers import NetBoxRouter
from . import views
app_name = "netbox_librenms_plugin"
router = NetBoxRouter()
router.register("interface-type-mappings", views.InterfaceTypeMappingViewSet)
urlpatterns = [
path("jobs/<int:job_pk>/sync-status/", views.sync_job_status, name="sync_job_status"),
] + router.urls

View File

@@ -0,0 +1,106 @@
import logging
from core.choices import JobStatusChoices
from core.models import Job
from django.http import JsonResponse
from django.utils import timezone
from django_rq import get_queue
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
logger = logging.getLogger(__name__)
class LibreNMSPluginPermission(BasePermission):
"""
Permission class for LibreNMS plugin API endpoints.
- 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):
if request.method in SAFE_METHODS:
return request.user.has_perm(PERM_VIEW_PLUGIN)
return request.user.has_perm(PERM_CHANGE_PLUGIN)
class InterfaceTypeMappingViewSet(NetBoxModelViewSet):
"""API viewset for InterfaceTypeMapping CRUD operations."""
permission_classes = [LibreNMSPluginPermission]
filterset_class = InterfaceTypeMappingFilterSet
queryset = InterfaceTypeMapping.objects.all()
serializer_class = InterfaceTypeMappingSerializer
@api_view(["POST"])
@permission_classes([LibreNMSPluginPermission])
def sync_job_status(request, job_pk):
"""
Sync database Job status with RQ job status.
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, user=request.user, name__in=_LIBRENMS_JOB_NAMES)
except Job.DoesNotExist:
return JsonResponse({"error": "Job not found"}, status=404)
# Get RQ job status
queue = get_queue("default")
try:
rq_job = RQJob.fetch(str(job.job_id), connection=queue.connection)
rq_status = rq_job.get_status()
# If RQ job is stopped or failed, update database
if rq_job.is_stopped or rq_job.is_failed:
job.status = JobStatusChoices.STATUS_FAILED
if not job.completed:
job.completed = timezone.now()
job.save(update_fields=["status", "completed"])
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 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)

View File

@@ -0,0 +1,6 @@
# Plugin permissions (from LibreNMSSettings model)
PERM_VIEW_PLUGIN = "netbox_librenms_plugin.view_librenmssettings"
PERM_CHANGE_PLUGIN = "netbox_librenms_plugin.change_librenmssettings"
# LibreNMS VLAN state values
LIBRENMS_VLAN_STATE_ACTIVE = 1

View File

@@ -0,0 +1,13 @@
import django_filters
from .models import InterfaceTypeMapping
class InterfaceTypeMappingFilterSet(django_filters.FilterSet):
"""Filter set for InterfaceTypeMapping model."""
class Meta:
"""Meta options for InterfaceTypeMappingFilterSet."""
model = InterfaceTypeMapping
fields = ["librenms_type", "librenms_speed", "netbox_type", "description"]

View File

@@ -0,0 +1,140 @@
import django_filters
from dcim.models import Device, DeviceRole, DeviceType, Platform, Site
from django import forms
from django.db.models import Q
from netbox.filtersets import NetBoxModelFilterSet
from virtualization.models import Cluster, VirtualMachine
class SiteLocationFilterSet:
"""
Filter sites and locations by search term.
"""
def __init__(self, data, queryset):
"""Initialize with form data and queryset."""
self.form_data = data
self.queryset = queryset
@property
def qs(self):
"""Return the filtered queryset."""
queryset = self.queryset
if q := self.form_data.get("q"):
return self._filter_queryset(q)
return queryset
def _filter_queryset(self, search_term):
"""Filter queryset by search term."""
search_term = str(search_term).lower()
return [item for item in self.queryset if self._matches_search_criteria(item, search_term)]
def _matches_search_criteria(self, item, search_term):
"""Check if item matches search criteria."""
searchable_fields = [
str(item.netbox_site.name),
str(item.netbox_site.latitude),
str(item.netbox_site.longitude),
str(item.librenms_location) if item.librenms_location else "",
]
return any(search_term in field.lower() for field in searchable_fields)
@property
def form(self):
"""Return a bound filter form instance."""
class FilterForm(forms.Form):
"""
Form to filter sites and locations by search term.
"""
q = forms.CharField(
required=False,
label="Search sites and locations",
widget=forms.TextInput(attrs={"placeholder": "Search by site name, coordinates or location"}),
)
return FilterForm(self.form_data)
class DeviceStatusFilterSet(NetBoxModelFilterSet):
"""
Filter devices by search term.
"""
device = django_filters.ModelMultipleChoiceFilter(
field_name="name",
queryset=Device.objects.all(),
)
site = django_filters.ModelMultipleChoiceFilter(
field_name="site",
queryset=Site.objects.all(),
)
device_type = django_filters.ModelMultipleChoiceFilter(
field_name="device_type",
queryset=DeviceType.objects.all(),
)
role = django_filters.ModelMultipleChoiceFilter(
field_name="role",
queryset=DeviceRole.objects.all(),
)
class Meta:
"""Meta options for DeviceStatusFilterSet."""
model = Device
fields = ["site", "location", "device_type", "rack", "role"]
search_fields = ["device", "site", "device_type", "rack", "role"]
def search(self, queryset, name, value):
"""Search devices by name, site, device type, rack or role."""
if not value.strip():
return queryset
return queryset.filter(
Q(name__icontains=value)
| Q(site__name__icontains=value)
| Q(device_type__model__icontains=value)
| Q(rack__name__icontains=value)
| Q(role__name__icontains=value)
)
class VMStatusFilterSet(NetBoxModelFilterSet):
"""
Filter virtual machines by search term.
"""
virtualmachine = django_filters.ModelMultipleChoiceFilter(
field_name="name",
queryset=VirtualMachine.objects.all(),
)
site = django_filters.ModelMultipleChoiceFilter(
field_name="site",
queryset=Site.objects.all(),
)
cluster = django_filters.ModelMultipleChoiceFilter(
field_name="cluster",
queryset=Cluster.objects.all(),
)
platform = django_filters.ModelMultipleChoiceFilter(
field_name="platform",
queryset=Platform.objects.all(),
)
class Meta:
"""Meta options for VMStatusFilterSet."""
model = VirtualMachine
fields = ["site", "cluster", "platform"]
search_fields = ["virtualmachine", "site", "cluster", "platform"]
def search(self, queryset, name, value):
"""Search VMs by name, site, cluster, or platform."""
if not value.strip():
return queryset
return queryset.filter(
Q(name__icontains=value)
| Q(site__name__icontains=value)
| Q(cluster__name__icontains=value)
| Q(platform__name__icontains=value)
)

View File

@@ -0,0 +1,787 @@
# forms.py
import logging
from dcim.choices import InterfaceTypeChoices
from dcim.models import Device, DeviceRole, DeviceType, Location, Rack, Site
from django import forms
from django.db.models import Case, IntegerField, Value, When
from django.http import QueryDict
from django.utils.translation import gettext_lazy as _
from netbox.forms import (
NetBoxModelFilterSetForm,
NetBoxModelForm,
NetBoxModelImportForm,
)
from netbox.plugins import get_plugin_config
from utilities.forms.fields import CSVChoiceField, DynamicModelMultipleChoiceField
from virtualization.models import Cluster, VirtualMachine
from .models import InterfaceTypeMapping, LibreNMSSettings
logger = logging.getLogger(__name__)
def _get_librenms_server_choices():
"""
Helper function to get server choices from plugin configuration.
Shared between ServerConfigForm and other forms that need server selection.
"""
choices = []
# Try to get multi-server configuration
servers_config = get_plugin_config("netbox_librenms_plugin", "servers")
if servers_config and isinstance(servers_config, dict):
# Multi-server configuration
for key, config in servers_config.items():
display_name = config.get("display_name", key)
url = config.get("librenms_url", "Unknown URL")
choices.append((key, f"{display_name} ({url})"))
else:
# Legacy single-server configuration
legacy_url = get_plugin_config("netbox_librenms_plugin", "librenms_url")
if legacy_url:
choices.append(("default", f"Default Server ({legacy_url})"))
else:
choices.append(("default", "Default Server"))
return choices
def _get_librenms_poller_group_choices():
"""
Helper function to get poller group choices from LibreNMS API.
Shared between AddToLIbreSNMPV1V2 and AddToLIbreSNMPV3 forms.
"""
from .librenms_api import LibreNMSAPI
choices = [("0", "Default (0)")]
try:
api = LibreNMSAPI()
success, poller_groups = api.get_poller_groups()
if success:
for group in poller_groups or []:
group_id = str(group.get("id", ""))
group_name = group.get("group_name", "")
group_descr = group.get("descr", "")
if group_id:
if group_descr and group_descr != group_name:
label = f"{group_name} - {group_descr} ({group_id})"
else:
label = f"{group_name} ({group_id})"
choices.append((group_id, label))
except Exception:
logger.exception("Failed to fetch LibreNMS poller groups; using default choices")
return choices
class ServerConfigForm(NetBoxModelForm):
"""
Form for selecting the active LibreNMS server from configured servers.
Handles server configuration changes only.
"""
selected_server = forms.ChoiceField(
label="LibreNMS Server",
help_text="Select which LibreNMS server to use for synchronization operations",
)
class Meta:
model = LibreNMSSettings
fields = ["selected_server"]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["selected_server"].choices = _get_librenms_server_choices()
class ImportSettingsForm(NetBoxModelForm):
"""
Form for configuring device import settings including naming patterns
and virtual chassis member naming.
"""
vc_member_name_pattern = forms.CharField(
label="Virtual Chassis Member Naming Pattern",
max_length=100,
required=False,
strip=False, # Preserve leading/trailing whitespace
widget=forms.TextInput(
attrs={
"placeholder": "-M{position}",
}
),
)
use_sysname_default = forms.BooleanField(
label="Use sysName",
required=False,
widget=forms.CheckboxInput(attrs={"class": "form-check-input"}),
help_text="Use SNMP sysName instead of LibreNMS hostname when importing devices",
)
strip_domain_default = forms.BooleanField(
label="Strip domain",
required=False,
widget=forms.CheckboxInput(attrs={"class": "form-check-input"}),
help_text="Remove domain suffix from device names during import",
)
class Meta:
model = LibreNMSSettings
fields = [
"vc_member_name_pattern",
"use_sysname_default",
"strip_domain_default",
]
def clean_vc_member_name_pattern(self):
"""
Validate VC member name pattern for valid placeholders and formatting.
The pattern is used as a suffix appended to the master device name.
Valid placeholders: {position}, {serial}
At least one is required for uniqueness.
"""
pattern = self.cleaned_data.get("vc_member_name_pattern")
if not pattern:
return pattern
# Check for valid placeholder names using regex
import re
valid_placeholders = {"position", "serial"}
found_placeholders = set(re.findall(r"\{(\w+)\}", pattern))
invalid_placeholders = found_placeholders - valid_placeholders
if invalid_placeholders:
invalid_list = ", ".join(f"{{{p}}}" for p in sorted(invalid_placeholders))
error_msg = f"Invalid placeholder(s): {invalid_list}. Valid options are: {{position}}, {{serial}}"
raise forms.ValidationError(error_msg)
# Check required: must have at least one unique identifier
if "{position}" not in pattern and "{serial}" not in pattern:
raise forms.ValidationError(
"The naming pattern must include either {{position}} or {{serial}} "
"placeholder to ensure unique member names."
)
# Test the pattern can be formatted without errors
test_vars = {
"position": 1,
"serial": "ABC123",
}
try:
test_result = pattern.format(**test_vars)
# Check result isn't empty or just whitespace
if not test_result.strip():
raise forms.ValidationError(
"The pattern results in an empty suffix. Please include some text content in the pattern."
)
except KeyError as e:
# This should be caught by check above, but just in case
raise forms.ValidationError(
f"Invalid placeholder in pattern: {e}. Valid options are: {{position}}, {{serial}}"
)
except (ValueError, IndexError) as e:
raise forms.ValidationError(f"Invalid pattern syntax: {str(e)}")
return pattern
# Keep for backward compatibility if needed elsewhere
class LibreNMSSettingsForm(ServerConfigForm):
"""
Deprecated: Use ServerConfigForm or ImportSettingsForm instead.
Kept for backward compatibility.
"""
pass
class InterfaceTypeMappingForm(NetBoxModelForm):
"""
Form for creating and editing interface type mappings between LibreNMS and NetBox.
Allows mapping of LibreNMS interface types and speeds to NetBox interface types.
"""
class Meta:
model = InterfaceTypeMapping
fields = ["librenms_type", "librenms_speed", "netbox_type", "description"]
class InterfaceTypeMappingImportForm(NetBoxModelImportForm):
"""
Form for bulk importing interface type mappings from CSV/JSON/YAML.
Supports importing LibreNMS interface type and speed mappings to NetBox interface types.
"""
netbox_type = CSVChoiceField(
label=_("NetBox Type"),
choices=InterfaceTypeChoices,
help_text=_("NetBox interface type"),
)
class Meta:
model = InterfaceTypeMapping
fields = ["librenms_type", "librenms_speed", "netbox_type", "description"]
class InterfaceTypeMappingFilterForm(NetBoxModelFilterSetForm):
"""
Form for filtering interface type mappings based on LibreNMS and NetBox attributes.
Provides filtering options for LibreNMS type, speed, and NetBox type.
"""
librenms_type = forms.CharField(required=False, label="LibreNMS Type")
librenms_speed = forms.IntegerField(
required=False,
label="LibreNMS Speed (Kbps)",
help_text="Filter by interface speed in Kbps",
)
netbox_type = forms.ChoiceField(
required=False,
label="NetBox Type",
choices=[("", "---------")] + list(InterfaceTypeChoices),
)
description = forms.CharField(
required=False,
label="Description",
help_text="Filter by description (partial match)",
)
model = InterfaceTypeMapping
class AddToLIbreSNMPV1V2(forms.Form):
"""
Form for adding devices to LibreNMS using SNMPv1 or SNMPv2c authentication.
Collects hostname/IP and SNMP community string information.
The SNMP version (v1 or v2c) is selected via a toggle button in the template.
"""
hostname = forms.CharField(
label="Hostname/IP",
max_length=255,
required=True,
)
community = forms.CharField(label="SNMP Community", max_length=255, required=True)
port = forms.IntegerField(
label="SNMP Port",
required=False,
help_text="Leave blank to use default SNMP port (161)",
widget=forms.NumberInput(attrs={"placeholder": "161"}),
)
transport = forms.ChoiceField(
label="Transport",
choices=[
("udp", "UDP"),
("tcp", "TCP"),
("udp6", "UDP6"),
("tcp6", "TCP6"),
],
required=False,
initial="udp",
)
port_association_mode = forms.ChoiceField(
label="Port Association Mode",
choices=[
("ifIndex", "ifIndex"),
("ifName", "ifName"),
("ifDescr", "ifDescr"),
("ifAlias", "ifAlias"),
],
required=False,
initial="ifIndex",
help_text="Method to identify ports",
)
poller_group = forms.ChoiceField(
label="Poller Group",
required=False,
help_text="Poller group for distributed poller setup",
)
force_add = forms.BooleanField(
label="Force Add",
required=False,
initial=False,
help_text="Skip duplicate device and SNMP reachability checks (hostname must still be unique)",
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["poller_group"].choices = _get_librenms_poller_group_choices()
class AddToLIbreSNMPV3(forms.Form):
"""
Form for adding devices to LibreNMS using SNMPv3 authentication.
Provides comprehensive SNMPv3 configuration options including authentication and encryption settings.
"""
hostname = forms.CharField(
label="Hostname/IP",
max_length=255,
required=True,
)
snmp_version = forms.CharField(widget=forms.HiddenInput(), initial="v3")
authlevel = forms.ChoiceField(
label="Auth Level",
choices=[
("noAuthNoPriv", "noAuthNoPriv"),
("authNoPriv", "authNoPriv"),
("authPriv", "authPriv"),
],
required=True,
)
authname = forms.CharField(label="Auth Username", max_length=255, required=True)
authpass = forms.CharField(
label="Auth Password",
max_length=255,
required=True,
widget=forms.PasswordInput(render_value=True),
)
authalgo = forms.ChoiceField(
label="Auth Algorithm",
choices=[
("SHA", "SHA"),
("MD5", "MD5"),
("SHA-224", "SHA-224"),
("SHA-256", "SHA-256"),
("SHA-384", "SHA-384"),
("SHA-512", "SHA-512"),
],
required=True,
)
cryptopass = forms.CharField(
label="Crypto Password",
max_length=255,
required=True,
widget=forms.PasswordInput(render_value=True),
)
cryptoalgo = forms.ChoiceField(
label="Crypto Algorithm",
choices=[("AES", "AES"), ("DES", "DES")],
required=True,
)
port = forms.IntegerField(
label="SNMP Port",
required=False,
help_text="Leave blank to use default SNMP port (161)",
widget=forms.NumberInput(attrs={"placeholder": "161"}),
)
transport = forms.ChoiceField(
label="Transport",
choices=[
("udp", "UDP"),
("tcp", "TCP"),
("udp6", "UDP6"),
("tcp6", "TCP6"),
],
required=False,
initial="udp",
)
port_association_mode = forms.ChoiceField(
label="Port Association Mode",
choices=[
("ifIndex", "ifIndex"),
("ifName", "ifName"),
("ifDescr", "ifDescr"),
("ifAlias", "ifAlias"),
],
required=False,
initial="ifIndex",
help_text="Method to identify ports",
)
poller_group = forms.ChoiceField(
label="Poller Group",
required=False,
help_text="Poller group for distributed poller setup",
)
force_add = forms.BooleanField(
label="Force Add",
required=False,
initial=False,
help_text="Skip duplicate device and SNMP reachability checks (hostname must still be unique)",
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["poller_group"].choices = _get_librenms_poller_group_choices()
class DeviceStatusFilterForm(NetBoxModelFilterSetForm):
"""
Filter form for Device Status view - shows NetBox devices and their LibreNMS status.
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Remove the saved filter field if it exists
if "filter_id" in self.fields:
del self.fields["filter_id"]
site = DynamicModelMultipleChoiceField(queryset=Site.objects.all(), required=False)
location = DynamicModelMultipleChoiceField(queryset=Location.objects.all(), required=False)
rack = DynamicModelMultipleChoiceField(queryset=Rack.objects.all(), required=False)
device_type = DynamicModelMultipleChoiceField(queryset=DeviceType.objects.all(), required=False)
role = DynamicModelMultipleChoiceField(queryset=DeviceRole.objects.all(), required=False)
model = Device
class LibreNMSImportFilterForm(forms.Form):
"""
Filter form for LibreNMS Import view - shows LibreNMS devices for import.
Uses a simple Django form instead of NetBox model forms.
"""
# LibreNMS filters
librenms_location = forms.ChoiceField(
required=False,
label="LibreNMS Location",
choices=[("", "All Locations")], # Default, will be populated in __init__
widget=forms.Select(attrs={"class": "form-select"}),
)
librenms_type = forms.ChoiceField(
required=False,
label="LibreNMS Type",
choices=[
("", "All Types"),
("network", "Network"),
("server", "Server"),
("storage", "Storage"),
("wireless", "Wireless"),
("firewall", "Firewall"),
("power", "Power"),
("appliance", "Appliance"),
("printer", "Printer"),
("loadbalancer", "Load Balancer"),
("other", "Other"),
],
)
librenms_os = forms.CharField(
required=False,
label="Operating System",
widget=forms.TextInput(attrs={"placeholder": "e.g., ios, linux, junos"}),
)
librenms_hostname = forms.CharField(
required=False,
label="LibreNMS Hostname",
widget=forms.TextInput(attrs={"placeholder": "Partial hostname match"}),
help_text="IP address or FQDN used to add device to LibreNMS",
)
librenms_sysname = forms.CharField(
required=False,
label="LibreNMS System Name",
widget=forms.TextInput(attrs={"placeholder": "Exact or partial sysName match"}),
help_text="SNMP sysName. (exact match only; combine with another filter for partial matching)",
)
librenms_hardware = forms.CharField(
required=False,
label="Hardware",
widget=forms.TextInput(attrs={"placeholder": "e.g., C9300-48P, ASR-920"}),
help_text="LibreNMS hardware model (partial match)",
)
show_disabled = forms.BooleanField(
required=False,
initial=False,
label="Include Disabled Devices",
widget=forms.CheckboxInput(attrs={"class": "form-check-input"}),
)
enable_vc_detection = forms.BooleanField(
required=False,
initial=False,
label="Include Virtual Chassis Detection",
help_text="Run additional stack checks during the search. Will increase processing time.",
widget=forms.CheckboxInput(attrs={"class": "form-check-input"}),
)
clear_cache = forms.BooleanField(
required=False,
initial=False,
label="Clear cache before search",
help_text="Discard the cache and pull fresh data from both LibreNMS and NetBox.",
widget=forms.CheckboxInput(attrs={"class": "form-check-input"}),
)
exclude_existing = forms.BooleanField(
required=False,
initial=False,
label="Exclude Existing Devices",
help_text="Hide devices that already exist in NetBox",
widget=forms.CheckboxInput(attrs={"class": "form-check-input"}),
)
use_background_job = forms.BooleanField(
required=False,
initial=True,
label="Run as background job",
help_text="Recommended: Jobs are logged and can be cancelled.",
widget=forms.CheckboxInput(attrs={"class": "form-check-input"}),
)
def __init__(self, *args, **kwargs):
"""Initialize the form and populate dynamic choices."""
# For bound forms, ensure use_background_job defaults to 'on' if not present
# This handles the case where checkbox is checked by default but not in GET params
# Only apply this default when no filters are applied (initial page load)
if args and isinstance(args[0], (dict, QueryDict)):
# Form is being bound with data (GET/POST dict or QueryDict)
data = args[0].copy() if hasattr(args[0], "copy") else dict(args[0])
# If use_background_job is not in the data, add it with default 'on'
# This makes the checkbox checked by default even on first submission
# Only do this if no filter fields are set (initial page load scenario)
filter_fields = [
"librenms_location",
"librenms_type",
"librenms_os",
"librenms_hostname",
"librenms_sysname",
"librenms_hardware",
]
has_filters = any(data.get(field) for field in filter_fields)
non_option_fields = [
f for f in filter_fields if data.get(f) not in (None, "", []) and str(data.get(f, "")).strip()
]
has_option_only = bool(data) and not bool(non_option_fields) and not has_filters
# Apply default only on initial load (no filters, no job_id, no real submission)
if "use_background_job" not in data and not data.get("job_id") and not has_filters and not has_option_only:
data["use_background_job"] = "on"
args = (data,) + args[1:]
super().__init__(*args, **kwargs)
# Populate LibreNMS location choices dynamically
self._populate_librenms_locations()
def clean(self):
cleaned_data = super().clean()
# Only enforce filter requirement when the user explicitly submits the form
if self.data.get("apply_filters"):
filter_fields = (
"librenms_location",
"librenms_type",
"librenms_os",
"librenms_hostname",
"librenms_sysname",
"librenms_hardware",
)
if not any(cleaned_data.get(field) for field in filter_fields):
raise forms.ValidationError("Please select at least one LibreNMS filter before applying the search.")
return cleaned_data
def _populate_librenms_locations(self):
"""Fetch and populate LibreNMS locations in the dropdown."""
from django.core.cache import cache
from netbox_librenms_plugin.import_utils.cache import get_location_choices_cache_key
from netbox_librenms_plugin.librenms_api import LibreNMSAPI
try:
# Determine server_key cheaply from settings to check cache before instantiating the API
try:
from netbox_librenms_plugin.models import LibreNMSSettings
_settings = LibreNMSSettings.objects.first()
_server_key = (_settings.selected_server if _settings else None) or "default"
except Exception:
_server_key = "default"
cache_key = get_location_choices_cache_key(_server_key)
cached_choices = cache.get(cache_key)
if cached_choices is not None:
self.fields["librenms_location"].choices = cached_choices
return
# Cache miss — instantiate the API client and fetch
api = LibreNMSAPI()
# Recompute cache_key with the resolved server_key in case it differs from settings
cache_key = get_location_choices_cache_key(api.server_key)
# Second cache check: the resolved server_key may differ from the settings key
cached_choices = cache.get(cache_key)
if cached_choices is not None:
self.fields["librenms_location"].choices = cached_choices
return
# Fetch locations from LibreNMS
success, locations = api.get_locations()
if success and locations:
# Build choices list: (id, name)
choices = [("", "All Locations")]
for loc in locations:
loc_id = str(loc.get("id", ""))
loc_name = loc.get("location", f"Location {loc_id}")
choices.append((loc_id, loc_name))
# Sort by name
choices[1:] = sorted(choices[1:], key=lambda x: x[1])
self.fields["librenms_location"].choices = choices
# Cache using configured timeout (default 300s)
cache.set(cache_key, choices, timeout=api.cache_timeout)
logger.info(f"Loaded {len(choices) - 1} LibreNMS locations")
else:
logger.warning(f"Failed to load LibreNMS locations: {locations}")
except Exception as e:
logger.exception(f"Error loading LibreNMS locations: {e}")
# Keep default choices on error
class VirtualMachineStatusFilterForm(NetBoxModelFilterSetForm):
"""
Form for filtering virtual machine status information in NetBox.
"""
def __init__(self, *args, **kwargs):
"""Initialize the form and remove the filter_id field if it exists."""
super().__init__(*args, **kwargs)
# Remove the saved filter field if it exists
if "filter_id" in self.fields:
del self.fields["filter_id"]
virtualmachine = DynamicModelMultipleChoiceField(queryset=VirtualMachine.objects.all(), required=False)
site = DynamicModelMultipleChoiceField(queryset=Site.objects.all(), required=False)
cluster = DynamicModelMultipleChoiceField(queryset=Cluster.objects.all(), required=False)
model = VirtualMachine
class DeviceImportConfigForm(forms.Form):
"""
Form for configuring import of LibreNMS devices with missing prerequisites.
Allows user to manually map LibreNMS device data to NetBox objects.
"""
device_id = forms.IntegerField(widget=forms.HiddenInput(), required=True)
hostname = forms.CharField(disabled=True, required=False, label="Device Hostname")
hardware = forms.CharField(disabled=True, required=False, label="Hardware")
librenms_location = forms.CharField(disabled=True, required=False, label="LibreNMS Location")
# Required mappings
site = forms.ModelChoiceField(
queryset=Site.objects.all(),
required=True,
label="NetBox Site",
help_text="Select the NetBox site for this device",
widget=forms.Select(attrs={"class": "form-select"}),
)
device_type = forms.ModelChoiceField(
queryset=DeviceType.objects.all(),
required=True,
label="Device Type",
help_text="Select the NetBox device type",
widget=forms.Select(attrs={"class": "form-select"}),
)
device_role = forms.ModelChoiceField(
queryset=DeviceRole.objects.all(),
required=True,
label="Device Role",
help_text="Select the device role",
widget=forms.Select(attrs={"class": "form-select"}),
)
# Optional mappings
platform = forms.ModelChoiceField(
queryset=None,
required=False,
label="Platform",
help_text="Select platform (optional)",
widget=forms.Select(attrs={"class": "form-select"}),
)
# Sync options
sync_interfaces = forms.BooleanField(
initial=True,
required=False,
label="Sync Interfaces",
help_text="Automatically sync interfaces from LibreNMS after import",
)
sync_cables = forms.BooleanField(
initial=True,
required=False,
label="Sync Cables",
help_text="Automatically sync cable connections from LibreNMS after import",
)
sync_ips = forms.BooleanField(
initial=True,
required=False,
label="Sync IP Addresses",
help_text="Automatically sync IP addresses from LibreNMS after import",
)
def __init__(self, *args, **kwargs):
"""
Initialize form with LibreNMS device data and validation results.
Accepts additional kwargs:
- libre_device: LibreNMS device dictionary
- validation: Validation result dictionary
- suggested_site: Pre-selected site
- suggested_device_type: Pre-selected device type
- suggested_role: Pre-selected device role
"""
# Extract custom kwargs
libre_device = kwargs.pop("libre_device", {})
validation = kwargs.pop("validation", {})
suggested_site = kwargs.pop("suggested_site", None)
suggested_device_type = kwargs.pop("suggested_device_type", None)
suggested_role = kwargs.pop("suggested_role", None)
super().__init__(*args, **kwargs)
# Import Platform here to avoid circular imports
from dcim.models import Platform
self.fields["platform"].queryset = Platform.objects.all()
# Set initial values from LibreNMS device
if libre_device:
self.fields["device_id"].initial = libre_device.get("device_id")
self.fields["hostname"].initial = libre_device.get("hostname", "")
self.fields["hardware"].initial = libre_device.get("hardware", "")
self.fields["librenms_location"].initial = libre_device.get("location", "")
# Set suggested values from validation
if suggested_site:
self.fields["site"].initial = suggested_site
elif validation and validation.get("site", {}).get("site"):
self.fields["site"].initial = validation["site"]["site"]
if suggested_device_type:
self.fields["device_type"].initial = suggested_device_type
elif validation and validation.get("device_type", {}).get("device_type"):
self.fields["device_type"].initial = validation["device_type"]["device_type"]
if suggested_role:
self.fields["device_role"].initial = suggested_role
elif validation and validation.get("device_role", {}).get("role"):
self.fields["device_role"].initial = validation["device_role"]["role"]
if validation and validation.get("platform", {}).get("platform"):
self.fields["platform"].initial = validation["platform"]["platform"]
# Filter device types by suggestions if available
if validation and validation.get("device_type", {}).get("suggestions"):
suggestions = validation["device_type"]["suggestions"]
if suggestions:
# Annotate with suggested_order so suggested types sort first
suggested_ids = [s["device_type"].id for s in suggestions]
priority = Case(
*[When(id=pk, then=Value(i)) for i, pk in enumerate(suggested_ids)],
default=Value(len(suggested_ids)),
output_field=IntegerField(),
)
self.fields["device_type"].queryset = DeviceType.objects.annotate(suggested_order=priority).order_by(
"suggested_order", "manufacturer__name", "model"
)

View File

@@ -0,0 +1,53 @@
"""
Utilities for importing devices from LibreNMS to NetBox.
This package provides functions for:
- Validating LibreNMS devices for import
- Retrieving filtered LibreNMS devices
- Importing single and multiple devices
- Smart matching of NetBox objects
- Permission checking for import operations
- Virtual chassis detection and creation
All imports below are intentional re-exports so that existing callers
can continue using ``from netbox_librenms_plugin.import_utils import X``.
The F401 suppressions prevent linters from flagging them as unused.
"""
from .bulk_import import ( # noqa: F401
bulk_import_devices,
bulk_import_devices_shared,
process_device_filters,
)
from .cache import ( # noqa: F401
get_active_cached_searches,
get_cache_metadata_key,
get_import_device_cache_key,
get_import_search_cache_key,
get_validated_device_cache_key,
)
from .device_operations import ( # noqa: F401
_determine_device_name,
fetch_device_with_cache,
get_librenms_device_by_id,
import_single_device,
validate_device_for_import,
)
from .filters import ( # noqa: F401
_apply_client_filters,
get_device_count_for_filters,
get_librenms_devices_for_import,
)
from .permissions import check_user_permissions, require_permissions # noqa: F401
from .virtual_chassis import ( # noqa: F401
_clone_virtual_chassis_data,
_generate_vc_member_name,
_vc_cache_key,
create_virtual_chassis_with_members,
detect_virtual_chassis_from_inventory,
empty_virtual_chassis_data,
get_virtual_chassis_data,
prefetch_vc_data_for_devices,
update_vc_member_suggested_names,
)
from .vm_operations import bulk_import_vms, create_vm_from_librenms # noqa: F401

View File

@@ -0,0 +1,706 @@
"""Bulk import orchestration for devices and filter processing."""
import hashlib
import logging
from typing import List
from django.core.cache import cache
from ..import_validation_helpers import apply_role_to_validation, recalculate_validation_status, remove_validation_issue
from ..librenms_api import LibreNMSAPI
from ..utils import find_by_librenms_id
from .cache import get_cache_metadata_key, get_import_device_cache_key, get_validated_device_cache_key
from .device_operations import import_single_device, validate_device_for_import
from .filters import _safe_disabled, get_librenms_devices_for_import
from .permissions import check_user_permissions, require_permissions
from .virtual_chassis import (
create_virtual_chassis_with_members,
empty_virtual_chassis_data,
prefetch_vc_data_for_devices,
)
logger = logging.getLogger(__name__)
def _is_job_cancelled(job) -> bool:
"""
Return True if a background job has been stopped or cancelled.
Checks RQ/Redis state only (reflects stop API calls immediately).
On Redis connectivity issues or a missing RQ job, returns False to avoid
false cancellation. Unexpected exceptions are logged and also return False.
"""
from django_rq import get_queue
from redis.exceptions import RedisError
from rq.exceptions import NoSuchJobError
from rq.job import Job as RQJob
try:
queue = get_queue("default")
rq_job = RQJob.fetch(str(job.job.job_id), connection=queue.connection)
return rq_job.is_failed or rq_job.is_stopped
except (RedisError, NoSuchJobError):
return False
except Exception:
logger.warning("Unexpected error checking RQ job cancellation state", exc_info=True)
return False
def bulk_import_devices_shared(
device_ids: List[int],
server_key: str = None,
sync_options: dict = None,
manual_mappings_per_device: dict = None,
libre_devices_cache: dict = None,
job=None,
user=None,
) -> dict:
"""
Shared function for importing multiple LibreNMS devices to NetBox.
Used by both synchronous imports and background jobs. Handles per-device error
collection and optional progress logging when job context is provided.
Args:
device_ids: List of LibreNMS device IDs to import
server_key: LibreNMS server configuration key
sync_options: Sync options to apply to all devices
manual_mappings_per_device: Dict mapping device_id to manual_mappings dict
Example: {1179: {'device_role_id': 5}, 1180: {'device_role_id': 3}}
libre_devices_cache: Optional dict mapping device_id to pre-fetched device data
to avoid redundant API calls. Example: {123: {...device_data...}}
job: Optional JobRunner instance for progress logging and cancellation checks
user: User performing the import (for permission checks). If job is provided,
user is extracted from job.job.user if not explicitly passed.
Returns:
dict: Bulk import result with structure:
{
'total': int,
'success': List[dict], # Successfully imported devices
'failed': List[dict], # Failed imports with errors
'skipped': List[dict], # Skipped devices (already exist, etc.)
'virtual_chassis_created': int # Number of VCs created
}
Raises:
PermissionDenied: If user lacks required permissions
Example:
>>> # Synchronous usage
>>> result = bulk_import_devices_shared([1, 2, 3, 4, 5], user=request.user)
>>> # Background job usage
>>> result = bulk_import_devices_shared([1, 2, 3], job=self)
"""
# Extract user from job if not explicitly provided
if user is None and job is not None:
user = getattr(job.job, "user", None)
# 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.
required_perms = [
"dcim.add_device",
"dcim.change_device",
"virtualization.add_virtualmachine",
]
require_permissions(user, required_perms, "import devices")
total = len(device_ids)
success_list = []
failed_list = []
skipped_list = []
vc_created_count = 0
processed_vc_domains = set() # Track VCs already created by domain
_cancelled = False
# Initialize API client once for all devices to avoid repeated config parsing
api = LibreNMSAPI(server_key=server_key)
for idx, device_id in enumerate(device_ids, start=1):
# Check for job cancellation on first iteration and every 5th thereafter.
if job and (idx == 1 or idx % 5 == 0) and _is_job_cancelled(job):
if job.logger:
job.logger.warning(f"Import job stopped at device {idx} of {total}")
else:
logger.warning(f"Import cancelled at device {idx} of {total}")
_cancelled = True
break
try:
# Use cached device data if available to avoid redundant API calls
if libre_devices_cache and device_id in libre_devices_cache:
libre_device = libre_devices_cache[device_id]
success = True
else:
success, libre_device = api.get_device_info(device_id)
if not success or not libre_device:
error_msg = f"Failed to retrieve device {device_id} from LibreNMS"
failed_list.append({"device_id": device_id, "error": error_msg})
if job and job.logger:
job.logger.error(error_msg)
else:
logger.error(error_msg)
continue
use_sysname_opt = sync_options.get("use_sysname", True) if sync_options else True
strip_domain_opt = sync_options.get("strip_domain", False) if sync_options else False
validation = validate_device_for_import(
libre_device,
api=api,
use_sysname=use_sysname_opt,
strip_domain=strip_domain_opt,
server_key=api.server_key,
# Import-time behavior: always evaluate VC state from live/cached
# LibreNMS inventory so stack members are created even when preview
# flags are stale or omitted.
include_vc_detection=True,
)
vc_data = validation.get("virtual_chassis", {})
if vc_data.get("is_stack", False):
has_vc_perm, _ = check_user_permissions(user, ["dcim.add_virtualchassis"])
if not has_vc_perm:
error_msg = f"Cannot import stack device {device_id}: missing permission dcim.add_virtualchassis"
failed_list.append({"device_id": device_id, "error": error_msg})
if job and job.logger:
job.logger.error(error_msg)
else:
logger.error(error_msg)
continue
# Build manual mappings from validation + any provided overrides
device_mappings = {}
# Get site and device_type from validation
if validation["site"].get("found") and validation["site"].get("site"):
device_mappings["site_id"] = validation["site"]["site"].id
if validation["device_type"].get("found") and validation["device_type"].get("device_type"):
device_mappings["device_type_id"] = validation["device_type"]["device_type"].id
if validation["platform"].get("found") and validation["platform"].get("platform"):
device_mappings["platform_id"] = validation["platform"]["platform"].id
# Override with any manual mappings provided for this device
if manual_mappings_per_device and device_id in manual_mappings_per_device:
device_mappings.update(manual_mappings_per_device[device_id])
result = import_single_device(
device_id,
server_key=api.server_key, # use resolved key, not raw parameter (may be None)
validation=validation,
sync_options=sync_options,
manual_mappings=device_mappings if device_mappings else None,
libre_device=libre_device,
)
if result["success"]:
success_list.append(
{
"device_id": device_id,
"device": result["device"],
"message": result["message"],
}
)
# Log progress after each successful import
if job and job.logger:
job.logger.info(f"Imported device {idx} of {total}")
# Handle virtual chassis creation for stacks
if vc_data.get("is_stack", False):
# Derive a stack-level dedup key from member serials so that all
# LibreNMS devices belonging to the same physical stack (e.g. each
# switch in a stacked chassis that appears as a separate device in
# LibreNMS) share the same key and VC creation is triggered only once.
# Fall back to device_id when no member serials are available.
member_serials = sorted(
serial
for m in vc_data.get("members", [])
if (serial := str(m.get("serial") or "").strip()) and serial != "-"
)
if member_serials:
vc_domain = f"librenms-stack-{','.join(member_serials)}"
else:
# No serials available — build a stable fingerprint from member name/model/position
# so all LibreNMS devices in the same physical stack share the same dedup key.
member_parts = sorted(
f"{m.get('name', '')}/{m.get('model', '')}:{m.get('position', 0)}"
for m in vc_data.get("members", [])
)
if member_parts:
fingerprint = hashlib.md5(",".join(member_parts).encode()).hexdigest()[:12]
vc_domain = f"librenms-stack-{fingerprint}"
else:
vc_domain = f"librenms-{device_id}"
# Only create VC if we haven't processed this stack yet.
# Permission was already validated before device import.
if vc_domain not in processed_vc_domains:
# 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"]})
else: # Failed to import
failed_list.append({"device_id": device_id, "error": result["error"]})
if job and job.logger:
job.logger.error(f"Failed to import device {device_id}: {result['error']}")
except Exception as e:
error_msg = f"Unexpected error importing device {device_id}: {str(e)}"
if job and job.logger:
job.logger.error(error_msg, exc_info=True)
else:
logger.exception(f"Unexpected error importing device {device_id}")
failed_list.append({"device_id": device_id, "error": str(e)})
return {
"total": total,
"success": success_list,
"failed": failed_list,
"skipped": skipped_list,
"virtual_chassis_created": vc_created_count,
"cancelled": _cancelled,
}
def bulk_import_devices(
device_ids: List[int],
server_key: str = None,
sync_options: dict = None,
manual_mappings_per_device: dict = None,
libre_devices_cache: dict = None,
user=None,
) -> dict:
"""
Import multiple LibreNMS devices to NetBox (synchronous).
This is the public API for synchronous imports. For background job usage,
use bulk_import_devices_shared() with a job context.
Args:
device_ids: List of LibreNMS device IDs to import
server_key: LibreNMS server configuration key
sync_options: Sync options to apply to all devices
manual_mappings_per_device: Dict mapping device_id to manual_mappings dict
Example: {1179: {'device_role_id': 5}, 1180: {'device_role_id': 3}}
libre_devices_cache: Optional dict mapping device_id to pre-fetched device data
to avoid redundant API calls. Example: {123: {...device_data...}}
user: User performing the import (for permission checks)
Returns:
dict: Bulk import result with structure:
{
'total': int,
'success': List[dict], # Successfully imported devices
'failed': List[dict], # Failed imports with errors
'skipped': List[dict], # Skipped devices (already exist, etc.)
'virtual_chassis_created': int # Number of VCs created
}
Raises:
PermissionDenied: If user lacks required permissions
"""
return bulk_import_devices_shared(
device_ids=device_ids,
server_key=server_key,
sync_options=sync_options,
manual_mappings_per_device=manual_mappings_per_device,
libre_devices_cache=libre_devices_cache,
job=None, # No job context for synchronous imports
user=user,
)
def _refresh_existing_device(validation: dict, libre_device: dict = None, server_key: str = "default") -> None:
"""
Refresh existing_device from DB to pick up changes made in NetBox since caching.
When existing_device is None (wasn't found at cache time), re-check if the device
was imported since caching by looking up librenms_id or hostname.
"""
existing = validation.get("existing_device")
if existing and hasattr(existing, "pk"):
try:
from dcim.models import Device
from virtualization.models import VirtualMachine
if validation.get("import_as_vm"):
refreshed = VirtualMachine.objects.filter(pk=existing.pk).first()
else:
refreshed = Device.objects.filter(pk=existing.pk).first()
if refreshed:
validation["existing_device"] = refreshed
if hasattr(refreshed, "role") and refreshed.role:
apply_role_to_validation(validation, refreshed.role, is_vm=bool(validation.get("import_as_vm")))
elif not validation.get("import_as_vm"):
validation["device_role"] = {"found": False, "role": None}
remove_validation_issue(validation, "role")
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
# validate_device_for_import logic.
validation["existing_device"] = None
validation["existing_match_type"] = None
# Clear stale device_role so is_ready is computed from scratch.
# Guard: VMs don't use device_role for readiness, so preserve any
# user-selected role rather than silently dropping it.
if not validation.get("import_as_vm"):
validation["device_role"] = {"found": False, "role": None}
recalculate_validation_status(validation, is_vm=bool(validation.get("import_as_vm")))
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
# existing_device was None at cache time — check if device was imported since
if not libre_device:
return
try:
from dcim.models import Device
from virtualization.models import VirtualMachine
import_as_vm = validation.get("import_as_vm", False)
Model = VirtualMachine if import_as_vm else Device
# Also check the opposite model — the LibreNMS object may have been
# imported as a VM even though import_as_vm=False (or vice versa).
CrossModel = Device if import_as_vm else VirtualMachine
librenms_id = libre_device.get("device_id")
hostname = libre_device.get("hostname", "")
sys_name = libre_device.get("sysName", "")
new_device = None
match_type = None
found_as_cross_model = False
def _lookup_in_model(m):
"""Return (device, match_type) for model m, or (None, None)."""
if librenms_id is not None and not isinstance(librenms_id, bool):
try:
dev = find_by_librenms_id(m, int(librenms_id), server_key)
if dev:
return dev, "librenms_id"
except (ValueError, TypeError):
pass
resolved_name = validation.get("resolved_name")
if resolved_name:
dev = m.objects.filter(name__iexact=resolved_name).first()
if dev:
return dev, "resolved_name"
if hostname:
dev = m.objects.filter(name__iexact=hostname).first()
if dev:
return dev, "hostname"
if sys_name:
dev = m.objects.filter(name__iexact=sys_name).first()
if dev:
return dev, "sysname"
return None, None
new_device, match_type = _lookup_in_model(Model)
if not new_device:
# Try the opposite model: catches cross-model imports that happened
# after the cache was built (e.g. LibreNMS device imported as VM).
new_device, match_type = _lookup_in_model(CrossModel)
if new_device:
found_as_cross_model = True
if new_device:
validation["existing_device"] = new_device
validation["existing_match_type"] = match_type
validation["can_import"] = False
validation["is_ready"] = False
# Determine actual model from the found object, not from import_as_vm flag
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:
apply_role_to_validation(validation, new_device.role, is_vm=False)
elif not actual_is_vm:
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}")
def _empty_return(return_cache_status: bool):
"""Centralised empty-result return value for process_device_filters."""
return ([], False) if return_cache_status else []
def process_device_filters(
api: LibreNMSAPI,
filters: dict,
vc_detection_enabled: bool,
clear_cache: bool,
show_disabled: bool,
exclude_existing: bool = False,
job=None,
request=None,
return_cache_status: bool = False,
use_sysname: bool = True,
strip_domain: bool = False,
) -> List[dict] | tuple[List[dict], bool]:
"""
Process LibreNMS device filters and return validated devices.
Shared function used by both synchronous view and background job processing.
Fetches devices, optionally pre-warms VC cache, validates each device, and
caches results for HTMX row updates.
Args:
api: LibreNMS API client instance
filters: Filter dict with location, type, os, hostname, sysname, hardware keys
vc_detection_enabled: Whether to detect virtual chassis
clear_cache: Whether to force cache refresh
show_disabled: Whether to include disabled devices
exclude_existing: Whether to exclude devices that already exist in NetBox
job: Optional JobRunner instance for logging job events
request: Optional Django request for client disconnect detection (synchronous only)
return_cache_status: When True, returns (devices, from_cache) tuple
use_sysname: If True, prefer sysName over hostname for device name resolution
strip_domain: If True, strip domain suffix from device name
Returns:
List[dict]: Validated devices with _validation key, or tuple of (devices, from_cache)
if return_cache_status is True. from_cache=True means data was loaded from existing
cache; from_cache=False means data was just fetched from LibreNMS.
"""
# Fetch devices from LibreNMS
if job:
job.logger.info(f"Fetching devices with filters: {filters}")
if _is_job_cancelled(job):
job.logger.warning("Job was stopped before fetching devices")
return _empty_return(return_cache_status)
else:
logger.info(f"Fetching devices with filters: {filters}")
# Always get cache status internally, even if not returning it
# We need it to determine if metadata should be updated
libre_devices, from_cache = get_librenms_devices_for_import(
api,
filters=filters,
force_refresh=clear_cache,
return_cache_status=True,
)
# Filter out disabled devices if requested. LibreNMS's "disabled" field (1=disabled,
# 0=enabled) reflects manual device disablement; "status" reflects SNMP reachability.
# show_disabled controls the former: hidden when disabled==1, shown regardless of status.
if not show_disabled:
libre_devices = [d for d in libre_devices if _safe_disabled(d) != 1]
if job:
job.logger.info(f"Found {len(libre_devices)} devices to process")
else:
logger.info(f"Found {len(libre_devices)} devices")
# Check for early cancellation before the expensive VC prefetch
if job and _is_job_cancelled(job):
job.logger.warning("Job was stopped before VC pre-fetch")
return _empty_return(return_cache_status)
# Pre-warm VC cache if needed
if vc_detection_enabled and libre_devices:
device_ids = [d["device_id"] for d in libre_devices]
if job:
job.logger.info(
f"Pre-fetching virtual chassis data for {len(device_ids)} devices. This may take some time..."
)
else:
logger.info(f"Pre-fetching VC data for {len(device_ids)} devices")
try:
prefetch_vc_data_for_devices(api, device_ids, force_refresh=clear_cache)
if job:
job.logger.info("Virtual chassis data pre-fetch completed")
except (BrokenPipeError, ConnectionError, IOError) as e:
if request:
logger.info(f"Client disconnected during VC prefetch: {e}")
return _empty_return(return_cache_status)
raise
# Validate each device
validated_devices = []
total = len(libre_devices)
# Always pass api so validate_device_for_import can run hardware/chassis lookups.
# vc_detection_enabled only gates VC-specific paths inside that function.
if job:
job.logger.info(f"Starting validation of {total} devices")
if _is_job_cancelled(job):
job.logger.warning("Job was already stopped before validation started")
return _empty_return(return_cache_status)
else:
logger.info(f"Validating {total} devices")
for idx, device in enumerate(libre_devices, 1):
# Check for job termination periodically
if (idx % 5 == 0 or idx == 1) and job and _is_job_cancelled(job):
job.logger.info(f"Job stopped at device {idx}/{total}. Exiting gracefully.")
return _empty_return(return_cache_status)
# Drop any cached validation/meta keys before recomputing
device.pop("_validation", None)
# Generate shared cache key for this validated device
device_id = device["device_id"]
cache_key = get_validated_device_cache_key(
server_key=api.server_key,
filters=filters,
device_id=device_id,
vc_enabled=vc_detection_enabled,
use_sysname=use_sysname,
strip_domain=strip_domain,
)
# Check if we already have cached validation for this device
# (only if not forcing refresh)
if not clear_cache:
cached_device = cache.get(cache_key)
if cached_device:
# Use cached validation
device["_validation"] = cached_device["_validation"]
# Refresh existing_device from DB to avoid stale data
# (user may have changed role, name, etc. in NetBox)
_refresh_existing_device(device["_validation"], libre_device=device, server_key=api.server_key)
# Apply exclude_existing filter if enabled
if exclude_existing:
validation = device["_validation"]
if validation["existing_device"]:
continue
validated_devices.append(device)
continue
# Not in cache or forcing refresh - validate now
try:
validation = validate_device_for_import(
device,
api=api,
include_vc_detection=vc_detection_enabled,
force_vc_refresh=False,
server_key=api.server_key,
use_sysname=use_sysname,
strip_domain=strip_domain,
)
except (BrokenPipeError, ConnectionError, IOError) as e:
if request:
logger.info(f"Client disconnected during device validation: {e}")
return _empty_return(return_cache_status)
raise
# Set VC detection metadata
if not vc_detection_enabled:
validation["virtual_chassis"] = empty_virtual_chassis_data()
# Apply exclude_existing filter if enabled
if exclude_existing and validation["existing_device"]:
continue
device["_validation"] = validation
validated_devices.append(device)
# Cache with TWO keys for different purposes:
# 1. Complex key (with filter context) - for full validated device with all metadata
cache.set(cache_key, device, timeout=api.cache_timeout)
# 2. Simple key (device ID only) - for quick device data lookup by role/rack updates
# This avoids redundant API calls when user interacts with dropdowns
simple_cache_key = get_import_device_cache_key(device_id, api.server_key)
# Cache just the raw device data (not the full validation result)
# This is what get_validated_device_with_selections() expects
device_data_only = {k: v for k, v in device.items() if k != "_validation"}
cache.set(simple_cache_key, device_data_only, timeout=api.cache_timeout)
# Store cache metadata (timestamp) for all filter operations
# This enables countdown display regardless of background job vs synchronous execution
# Always store metadata when we have validated devices, even if from_cache
# This ensures metadata is available for countdown display
if validated_devices:
from datetime import datetime, timezone
cache_metadata_key = get_cache_metadata_key(
server_key=api.server_key,
filters=filters,
vc_enabled=vc_detection_enabled,
use_sysname=use_sysname,
strip_domain=strip_domain,
)
# Check if metadata already exists to preserve original timestamp
# BUT: if clear_cache was requested or data came fresh from LibreNMS, update it
existing_metadata = cache.get(cache_metadata_key)
should_update = clear_cache or not from_cache
if existing_metadata and not should_update:
# Metadata exists and cache wasn't cleared, keep using it (preserves original cache time)
pass
else:
# No metadata exists, OR cache was cleared, OR fresh data - create/update it now
cache_metadata = {
"cached_at": datetime.now(timezone.utc).isoformat(),
"cache_timeout": api.cache_timeout,
"filters": filters,
"vc_enabled": vc_detection_enabled,
"device_count": len(validated_devices),
}
cache.set(cache_metadata_key, cache_metadata, timeout=api.cache_timeout)
# Maintain cache index for this server to enable listing active searches
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)
# Always re-write the index so its TTL matches the freshly-written metadata.
# Without this the index can expire before the metadata and the active
# search entry disappears from the UI.
cache.set(cache_index_key, cache_index, timeout=api.cache_timeout)
if job:
if exclude_existing:
filtered_count = total - len(validated_devices)
job.logger.info(
f"Validation complete: {len(validated_devices)} devices passed filter, "
f"{filtered_count} filtered out (existing devices excluded)"
)
else:
job.logger.info(f"Validation complete: {len(validated_devices)} devices ready for import")
else:
logger.info(f"Processed {len(validated_devices)} validated devices")
if return_cache_status:
return validated_devices, from_cache
return validated_devices

View File

@@ -0,0 +1,232 @@
"""Cache key generation and management for device import operations."""
import hashlib
import json
import logging
from django.core.cache import cache
logger = logging.getLogger(__name__)
def _build_filter_hash(filters: dict) -> str:
"""
Build a stable, collision-free hash from a filter dict.
Removes None values (preserves valid falsy values like 0 and False),
sorts by key, and returns the first 16 hex characters of the SHA-256
digest of the JSON-serialized result.
"""
return hashlib.sha256(
json.dumps({k: v for k, v in filters.items() if v is not None}, sort_keys=True, separators=(",", ":")).encode()
).hexdigest()[:16]
def get_location_choices_cache_key(server_key: str) -> str:
"""Return the cache key for LibreNMS location choices for a given server."""
return f"librenms_locations_choices:{server_key}"
def get_cache_metadata_key(
server_key: str, filters: dict, vc_enabled: bool, use_sysname: bool = True, strip_domain: bool = False
) -> str:
"""
Generate a consistent cache metadata key from filter parameters.
Args:
server_key: LibreNMS server identifier
filters: Filter dictionary
vc_enabled: Whether VC detection is enabled
use_sysname: Whether sysName is preferred over hostname for device naming
strip_domain: Whether domain suffix is stripped from device names
Returns:
str: Consistent cache key for metadata
"""
# Sort filter items to ensure consistent key generation; use "is not None" to preserve
# valid falsy values like 0 and False (filtering only None/missing entries).
# Use JSON serialization for a stable, collision-free hash (avoids issues with
# values containing "=" or "_" that could collide with the key separators).
filter_hash = _build_filter_hash(filters)
return f"librenms_filter_cache_metadata_{server_key}_{filter_hash}_{vc_enabled}_sysname={use_sysname}_strip={strip_domain}"
def get_active_cached_searches(server_key: str) -> list[dict]:
"""
Retrieve all active cached searches for a server and enrich with display-friendly values.
Enriches raw filter IDs with human-readable names by looking up location names
from cached choices and converting type codes to display names.
Args:
server_key: LibreNMS server identifier
Returns:
List of dicts containing cache metadata with enriched display_filters
"""
from datetime import datetime, timezone
cache_index_key = f"librenms_cache_index_{server_key}"
cache_index = cache.get(cache_index_key, [])
active_searches = []
valid_cache_keys = []
# Get location and type choices for enriching display
location_choices = {}
type_choices = {
"": "All Types",
"network": "Network",
"server": "Server",
"storage": "Storage",
"wireless": "Wireless",
"firewall": "Firewall",
"power": "Power",
"appliance": "Appliance",
"printer": "Printer",
"loadbalancer": "Load Balancer",
"other": "Other",
}
# Get cached location choices for enrichment; scoped by server_key so labels
# from different LibreNMS servers don't bleed into each other's filter summaries.
location_cache_key = get_location_choices_cache_key(server_key)
cached_locations = cache.get(location_cache_key)
if cached_locations:
location_choices = dict(cached_locations)
for cache_key in cache_index:
metadata = cache.get(cache_key)
if metadata:
# Cache still exists, calculate time remaining
cache_timeout = metadata.get("cache_timeout", 300)
now = datetime.now(timezone.utc)
try:
cached_at_raw = metadata.get("cached_at")
if isinstance(cached_at_raw, datetime):
cached_at = cached_at_raw
elif cached_at_raw:
cached_at = datetime.fromisoformat(cached_at_raw)
else:
cached_at = datetime.fromtimestamp(0, timezone.utc)
# Normalize naive datetimes (e.g., stored without tzinfo) to UTC
if cached_at.tzinfo is None:
cached_at = cached_at.replace(tzinfo=timezone.utc)
except (ValueError, TypeError):
cached_at = datetime.fromtimestamp(0, timezone.utc)
age_seconds = (now - cached_at).total_seconds()
remaining_seconds = max(0, cache_timeout - age_seconds)
if remaining_seconds > 0:
# Add remaining time and cache key
metadata["remaining_seconds"] = int(remaining_seconds)
metadata["cache_key"] = cache_key
# Store numeric sort key so the final sort is unambiguous
metadata["cached_at_ts"] = cached_at.timestamp()
# Enrich filters with human-readable display values
if "filters" in metadata:
display_filters = metadata["filters"].copy()
# Convert location ID to location name
if "location" in display_filters and display_filters["location"] in location_choices:
display_filters["location"] = location_choices[display_filters["location"]]
# Convert type code to display name
if "type" in display_filters and display_filters["type"] in type_choices:
display_filters["type"] = type_choices[display_filters["type"]]
metadata["display_filters"] = display_filters
else:
# Fallback if filters key missing
metadata["display_filters"] = {}
active_searches.append(metadata)
valid_cache_keys.append(cache_key)
# Clean up index if any keys have expired
if len(valid_cache_keys) < len(cache_index):
cache.set(cache_index_key, valid_cache_keys, timeout=3600)
# Sort by most recent first
active_searches.sort(key=lambda x: x.get("cached_at_ts", 0.0), reverse=True)
return active_searches
def get_validated_device_cache_key(
server_key: str,
filters: dict,
device_id: int | str,
vc_enabled: bool,
use_sysname: bool = True,
strip_domain: bool = False,
) -> str:
"""
Generate a consistent cache key for validated device data.
This ensures both synchronous and background job processing use the same
cache keys, avoiding duplicate validation work and cache entries.
Args:
server_key: LibreNMS server key
filters: Filter dict with location, type, os, hostname, sysname, hardware keys
device_id: LibreNMS device ID
vc_enabled: Whether virtual chassis detection was enabled
use_sysname: Whether sysName is preferred over hostname for device naming
strip_domain: Whether domain suffix is stripped from device names
Returns:
str: Cache key for the validated device
Example:
>>> key = get_validated_device_cache_key('default', {'location': 'NYC'}, 123, True)
>>> key
'validated_device_default_e3b0c44298fc1c14_123_vc'
"""
# Sort filters for a deterministic, cross-process stable hash; None values are excluded
# (consistent with get_cache_metadata_key).
filter_hash = _build_filter_hash(filters)
vc_part = "vc" if vc_enabled else "novc"
return (
f"validated_device_{server_key}_{filter_hash}_{device_id}_{vc_part}_sysname={use_sysname}_strip={strip_domain}"
)
def get_import_device_cache_key(device_id: int | str, server_key: str = "default") -> str:
"""
Generate cache key for raw LibreNMS device data.
This key is used to cache raw device data (without validation metadata)
to avoid redundant API calls when users interact with dropdowns during
the import workflow.
Args:
device_id: LibreNMS device ID
server_key: LibreNMS server identifier for multi-server setups. Defaults to "default" for backward compatibility.
Returns:
str: Cache key for the device data
Example:
>>> get_import_device_cache_key(123, "production")
'import_device_data_production_123'
"""
return f"import_device_data_{server_key}_{device_id}"
def get_import_search_cache_key(server_key: str, api_filters: dict, client_filters: dict) -> str:
"""
Generate a deterministic cache key for a LibreNMS device search result.
The key encodes the server, API-side filters, and client-side filters so
that different filter combinations produce distinct cache entries.
Args:
server_key: Resolved LibreNMS server key (use ``api.server_key``).
api_filters: Filters forwarded to the LibreNMS API.
client_filters: Filters applied client-side after the API response.
Returns:
str: Cache key for the import search result.
"""
return (
f"librenms_devices_import_{server_key}_{_build_filter_hash(api_filters)}_{_build_filter_hash(client_filters)}"
)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,288 @@
"""Device filtering and retrieval from LibreNMS."""
import logging
from typing import List
from django.core.cache import cache
from .cache import get_import_search_cache_key
from ..librenms_api import LibreNMSAPI
logger = logging.getLogger(__name__)
def _safe_disabled(device: dict) -> int:
"""
Return 1 if the device is disabled, 0 otherwise.
Handles None, booleans, numeric strings, and common truthy/falsy tokens
(e.g. "true"/"yes"/"on" → 1, "false"/"no"/"off" → 0) without raising.
"""
val = device.get("disabled", 0)
if isinstance(val, bool):
return int(val)
if isinstance(val, str):
normalized = val.strip().lower()
if normalized in ("1", "true", "yes", "on"):
return 1
if normalized in ("0", "false", "no", "off", ""):
return 0
try:
int_val = int(val)
return 1 if int_val else 0
except (TypeError, ValueError):
return 0
def get_device_count_for_filters(
api: LibreNMSAPI,
filters: dict,
clear_cache: bool = False,
show_disabled: bool = True,
) -> int:
"""
Get count of LibreNMS devices matching filters.
This is a lightweight function to determine device count for background job
decision making. Uses the same caching as get_librenms_devices_for_import().
Args:
api: LibreNMS API client instance
filters: Filter dict with location, type, os, hostname, sysname keys
clear_cache: Whether to force cache refresh
show_disabled: Whether to include disabled devices
Returns:
int: Count of devices matching filters
"""
devices = get_librenms_devices_for_import(api, filters=filters, force_refresh=clear_cache)
# Filter out disabled devices if requested. LibreNMS's "disabled" field (1=disabled,
# 0=enabled) reflects manual device disablement; "status" reflects SNMP reachability.
# show_disabled controls the former: hidden when disabled==1, shown regardless of status.
if not show_disabled:
devices = [d for d in devices if _safe_disabled(d) != 1]
return len(devices)
def get_librenms_devices_for_import(
api: LibreNMSAPI = None,
filters: dict = None,
server_key: str = None,
*,
force_refresh: bool = False,
return_cache_status: bool = False,
) -> List[dict] | tuple[List[dict], bool]:
"""
Retrieve LibreNMS devices based on filters.
Args:
api: LibreNMSAPI instance (if not provided, creates one with server_key)
filters: Dict containing filter parameters:
- location: LibreNMS location/site filter
- type: Device type filter
- os: Operating system filter
- hostname: Hostname filter (partial match)
- sysname: System name filter (partial match)
- status: Device status filter (1=up, 0=down)
- disabled: Include disabled devices (0=active only, 1=all)
server_key: Key for specific server configuration (used if api not provided)
force_refresh: When True, bypass the cache and fetch fresh data
return_cache_status: When True, returns (devices, from_cache) tuple
Returns:
List of device dictionaries from LibreNMS, or tuple of (devices, from_cache)
if return_cache_status is True. from_cache=True means data was loaded from
existing cache; from_cache=False means data was just fetched from LibreNMS.
"""
try:
# Use provided API instance or create a new one
if api is None:
api = LibreNMSAPI(server_key=server_key)
# Build LibreNMS API filters using the type/query format
# LibreNMS API v0 expects ?type=X&query=Y format, not direct parameters
# NOTE: API only supports ONE type/query pair, so we'll use the most
# specific filter for the API and apply others client-side
api_filters = {}
client_filters = {} # Filters to apply after fetching from API
if filters:
# Check for status filter first - it has special handling
if filters.get("status") is not None:
# Normalize to int: form fields send strings ("1"/"0"), API may send ints
try:
status_val = int(filters["status"])
except (ValueError, TypeError):
status_val = None
# Status filter uses special types that don't need query param
if status_val == 1:
api_filters["type"] = "up"
elif status_val == 0:
api_filters["type"] = "down"
# Save ALL other filters for client-side filtering when status is used
if filters.get("location"):
client_filters["location"] = filters["location"]
if filters.get("type"):
client_filters["type"] = filters["type"]
if filters.get("os"):
client_filters["os"] = filters["os"]
if filters.get("hostname"):
client_filters["hostname"] = filters["hostname"]
if filters.get("sysname"):
client_filters["sysname"] = filters["sysname"]
if filters.get("hardware"):
client_filters["hardware"] = filters["hardware"]
else:
# Priority order for type/query filters: location > type > os > hostname > sysname
# Note: When sysname is combined with other filters, it's applied client-side for partial matching
# When sysname is alone, it uses API exact match (type=sysName)
# Note: hardware is always applied client-side for partial matching
# Use first available for API, save others for client-side filtering
if filters.get("location"):
api_filters["type"] = "location_id"
api_filters["query"] = filters["location"]
# Save remaining filters for client-side
if filters.get("type"):
client_filters["type"] = filters["type"]
if filters.get("os"):
client_filters["os"] = filters["os"]
if filters.get("hostname"):
client_filters["hostname"] = filters["hostname"]
if filters.get("sysname"):
client_filters["sysname"] = filters["sysname"]
if filters.get("hardware"):
client_filters["hardware"] = filters["hardware"]
elif filters.get("type"):
api_filters["type"] = "type"
api_filters["query"] = filters["type"]
# Save remaining filters for client-side
if filters.get("os"):
client_filters["os"] = filters["os"]
if filters.get("hostname"):
client_filters["hostname"] = filters["hostname"]
if filters.get("sysname"):
client_filters["sysname"] = filters["sysname"]
if filters.get("hardware"):
client_filters["hardware"] = filters["hardware"]
elif filters.get("os"):
api_filters["type"] = "os"
api_filters["query"] = filters["os"]
# Save remaining filters for client-side
if filters.get("hostname"):
client_filters["hostname"] = filters["hostname"]
if filters.get("sysname"):
client_filters["sysname"] = filters["sysname"]
if filters.get("hardware"):
client_filters["hardware"] = filters["hardware"]
elif filters.get("hostname"):
api_filters["type"] = "hostname"
api_filters["query"] = filters["hostname"]
# Save sysname and hardware for client-side
if filters.get("sysname"):
client_filters["sysname"] = filters["sysname"]
if filters.get("hardware"):
client_filters["hardware"] = filters["hardware"]
elif filters.get("sysname"):
# sysname-only filter: Use API exact match (type=sysName&query=<value>)
# This is safe - returns empty if no exact match found
api_filters["type"] = "sysName"
api_filters["query"] = filters["sysname"]
# Save hardware for client-side
if filters.get("hardware"):
client_filters["hardware"] = filters["hardware"]
elif filters.get("hardware"):
# hardware-only filter: apply client-side for partial matching
client_filters["hardware"] = filters["hardware"]
# Note: disabled filter isn't directly supported by LibreNMS API
# We'll filter client-side if needed
# Use caching to avoid repeated API calls
# Include both API and client filters in cache key (deterministic, cross-process stable).
# Use api.server_key (always resolved) rather than the raw server_key arg (may differ).
cache_key = get_import_search_cache_key(api.server_key, api_filters, client_filters)
from_cache = False
if force_refresh:
cache.delete(cache_key)
else:
cached_result = cache.get(cache_key)
if cached_result is not None:
# No need to deepcopy - cached data isn't mutated
devices = cached_result
from_cache = True
if return_cache_status:
return devices, from_cache
return devices
success, devices = api.list_devices(api_filters if api_filters else None)
if not success:
logger.error(f"Failed to retrieve devices from LibreNMS: {devices}")
# Cache a brief negative result to prevent hammering the API on repeated failures.
cache.set(cache_key, [], timeout=min(60, api.cache_timeout))
if return_cache_status:
return [], False
return []
# Apply client-side filters if any
if client_filters:
devices = _apply_client_filters(devices, client_filters)
# Cache using configured timeout (default 300s)
# No need to deepcopy - Django's cache backend handles serialization
cache.set(cache_key, devices, timeout=api.cache_timeout)
if return_cache_status:
return devices, from_cache
return devices
except Exception:
logger.exception("Error retrieving LibreNMS devices for import")
if return_cache_status:
return [], False
return []
def _apply_client_filters(devices: List[dict], filters: dict) -> List[dict]:
"""
Apply client-side filters to device list.
Args:
devices: List of device dicts from LibreNMS
filters: Dict of filters to apply (location, type, os, hostname, sysname)
Returns:
Filtered list of devices
"""
filtered = devices
if filters.get("location"):
location_id = str(filters["location"])
filtered = [d for d in filtered if str(d.get("location_id", "")) == location_id]
if filters.get("type"):
device_type = filters["type"].lower()
filtered = [d for d in filtered if (d.get("type") or "").lower() == device_type]
if filters.get("os"):
os_filter = filters["os"].lower()
filtered = [d for d in filtered if os_filter in (d.get("os") or "").lower()]
if filters.get("hostname"):
hostname_filter = filters["hostname"].lower()
filtered = [d for d in filtered if hostname_filter in (d.get("hostname") or "").lower()]
if filters.get("sysname"):
sysname_filter = filters["sysname"].lower()
filtered = [d for d in filtered if sysname_filter in (d.get("sysName") or "").lower()]
if filters.get("hardware"):
hardware_filter = filters["hardware"].lower()
filtered = [d for d in filtered if hardware_filter in (d.get("hardware") or "").lower()]
return filtered

View File

@@ -0,0 +1,44 @@
"""Permission check helpers for device import operations."""
from django.core.exceptions import PermissionDenied
def check_user_permissions(user, permissions):
"""
Check if user has all required permissions.
Args:
user: The user object to check permissions for
permissions: List of permission strings (e.g., ['dcim.add_device', 'dcim.add_interface'])
Returns:
tuple: (has_all_permissions: bool, missing_permissions: list[str])
Raises:
PermissionDenied: If user is None (no user context available)
"""
if user is None:
raise PermissionDenied("No user context available for permission check")
missing = [perm for perm in permissions if not user.has_perm(perm)]
return (len(missing) == 0, missing)
def require_permissions(user, permissions, action_description="perform this action"):
"""
Require user has all permissions, raising PermissionDenied if not.
Args:
user: The user object to check permissions for
permissions: List of permission strings
action_description: Human-readable description for error message
Raises:
PermissionDenied: If user lacks any required permission
"""
has_perms, missing = check_user_permissions(user, permissions)
if not has_perms:
missing_str = ", ".join(missing)
raise PermissionDenied(
f"You do not have permission to {action_description}. Missing permissions: {missing_str}"
)

View File

@@ -0,0 +1,640 @@
"""Virtual chassis detection, creation, and management."""
import logging
from typing import List
from dcim.models import Device, VirtualChassis
from django.core.cache import cache
from django.db import transaction
from ..librenms_api import LibreNMSAPI
logger = logging.getLogger(__name__)
def empty_virtual_chassis_data() -> dict:
"""Public helper for callers that need a blank VC payload."""
return {
"is_stack": False,
"member_count": 0,
"members": [],
"detection_error": None,
}
def _clone_virtual_chassis_data(data: dict | None) -> dict:
"""Return a defensive copy of cached VC data to avoid shared references."""
if not data:
return empty_virtual_chassis_data()
members = []
for idx, member in enumerate(data.get("members", [])):
member_copy = member.copy()
raw_position = member_copy.get("position", idx + 1)
try:
pos = int(raw_position)
member_copy["position"] = pos if pos > 0 else idx + 1
except (TypeError, ValueError):
member_copy["position"] = idx + 1 # 1-based fallback; position 0 is invalid
members.append(member_copy)
member_count = data.get("member_count") or len(members)
return {
"is_stack": bool(data.get("is_stack")),
"member_count": member_count,
"members": members,
"detection_error": data.get("detection_error"),
}
_VC_CACHE_VERSION = "v1"
def _vc_cache_key(api: LibreNMSAPI, device_id: int | str) -> str:
server_key = getattr(api, "server_key", "default")
return f"librenms_vc_detection_{_VC_CACHE_VERSION}_{server_key}_{device_id}"
def get_virtual_chassis_data(api: LibreNMSAPI, device_id: int | str, *, force_refresh: bool = False) -> dict:
"""Fetch (and cache) virtual chassis data for a LibreNMS device."""
if not api or device_id is None:
return empty_virtual_chassis_data()
cache_key = _vc_cache_key(api, device_id)
_cache_timeout = getattr(api, "cache_timeout", None)
cache_timeout = 300 if _cache_timeout is None else _cache_timeout
if not force_refresh and cache_timeout != 0:
cached = cache.get(cache_key)
if cached is not None:
return _clone_virtual_chassis_data(cached)
detection_data = detect_virtual_chassis_from_inventory(api, device_id)
if detection_data is None:
# Non-stack device or transient API failure — cache the negative result so
# prefetch_vc_data_for_devices() can skip these on subsequent renders.
# Use force_refresh=True to bypass the cache if needed.
empty = empty_virtual_chassis_data()
if cache_timeout != 0:
cache.set(cache_key, empty, timeout=cache_timeout)
return _clone_virtual_chassis_data(empty)
if "detection_error" not in detection_data:
detection_data["detection_error"] = None
cache_value = _clone_virtual_chassis_data(detection_data)
if cache_timeout != 0:
cache.set(cache_key, cache_value, timeout=cache_timeout)
return _clone_virtual_chassis_data(cache_value)
def prefetch_vc_data_for_devices(api: LibreNMSAPI, device_ids: List[int], *, force_refresh: bool = False) -> None:
"""
Pre-warm the virtual chassis cache for multiple devices.
This eliminates the 0.5-1s delay when rendering the import table
by proactively fetching VC data before validation.
Args:
api: LibreNMSAPI instance
device_ids: List of LibreNMS device IDs to prefetch VC data for
force_refresh: When True, bypass cache and fetch fresh data
Example:
>>> # Before rendering import table
>>> prefetch_vc_data_for_devices(api, [123, 124, 125])
>>> # Now all validate_device_for_import() calls hit cache instantly
"""
if not api or not device_ids:
return
logger.debug(f"Pre-warming VC cache for {len(device_ids)} devices")
for idx, device_id in enumerate(device_ids):
# This populates the cache if empty, or skips if already cached
try:
get_virtual_chassis_data(api, device_id, force_refresh=force_refresh)
except (BrokenPipeError, ConnectionError, IOError, OSError) as e:
logger.warning(f"Connection error during VC prefetch at device {idx}: {e}")
# Stop processing if connection is broken
return
except Exception as e:
# Log but continue for other errors
logger.warning(f"Error prefetching VC data for device {device_id}: {e}")
logger.debug(f"VC cache warming complete for {len(device_ids)} devices")
def detect_virtual_chassis_from_inventory(api: LibreNMSAPI, device_id: int) -> dict | None:
"""
Detect if device is a stack/Virtual Chassis by analyzing ENTITY-MIB inventory.
Vendor-agnostic using standard hierarchical structure.
Args:
api: LibreNMSAPI instance
device_id: LibreNMS device ID
Returns:
dict with structure:
{
'is_stack': bool,
'member_count': int,
'members': [
{
'serial': str,
'position': int,
'model': str,
'name': str,
'index': int,
'description': str,
'suggested_name': str # Generated using master device name
}
]
}
Returns None if not a stack or detection fails.
Detection Logic:
1. Check root level (entPhysicalContainedIn=0) for parent container
2. Find parent index (entPhysicalClass='stack' or 'chassis')
3. Get children chassis at that parent's index
4. If multiple chassis found -> Stack detected
"""
try:
# Get the master device info to use for naming
success, device_info = api.get_device_info(device_id)
master_name = None
if success and device_info:
master_name = device_info.get("sysName") or device_info.get("hostname")
# Step 1: Get root level items
success, root_items = api.get_inventory_filtered(device_id, ent_physical_contained_in=0)
if not success or not root_items:
logger.debug(f"No root inventory items found for device {device_id}")
return None
# Step 2: Find parent container index
# Prefer "stack" over "chassis" for deterministic VC detection
parent_index = None
stack_index = None
chassis_index = None
for item in root_items:
item_class = item.get("entPhysicalClass")
if item_class == "stack" and stack_index is None:
stack_index = item.get("entPhysicalIndex")
elif item_class == "chassis" and chassis_index is None:
chassis_index = item.get("entPhysicalIndex")
parent_index = stack_index if stack_index is not None else chassis_index
if parent_index is not None:
logger.debug(f"VC detection: Found parent container at index {parent_index} for device {device_id}")
if parent_index is None:
return None
# Step 3: Get children chassis at next level
success, child_items = api.get_inventory_filtered(
device_id,
ent_physical_class="chassis",
ent_physical_contained_in=parent_index,
)
if not success:
return None
# Filter for chassis only (in case API filter didn't work)
chassis_items = [item for item in (child_items or []) if item.get("entPhysicalClass") == "chassis"]
# Step 4: Multiple chassis = stack
if len(chassis_items) <= 1:
return None
# Step 5: Extract member info
# First pass: collect raw entPhysicalParentRelPos values to detect 0-based
# indexing. Some vendors use 0-based positions (0,1,2,3,4) instead of the
# RFC 2737 standard 1-based (1,2,3,4,5). If any raw position is 0, shift
# all valid positions up by 1 so the resulting set is always 1-based.
raw_positions = []
for chassis in chassis_items:
raw = chassis.get("entPhysicalParentRelPos")
try:
raw_positions.append(int(raw))
except (TypeError, ValueError):
raw_positions.append(None)
valid_positions = [p for p in raw_positions if p is not None]
zero_based = bool(valid_positions) and min(valid_positions) == 0
# Identify the master member by matching the LibreNMS device serial
# against the ENTITY-MIB serials. The device-level serial reported by
# LibreNMS corresponds to the active/master switch in the stack.
device_serial = ""
if device_info:
device_serial = _norm_serial(device_info.get("serial"))
# Load naming pattern once to avoid a DB query per member.
vc_name_pattern = _load_vc_member_name_pattern() if master_name else None
members = []
for idx, chassis in enumerate(chassis_items):
raw_pos = raw_positions[idx]
if raw_pos is not None:
position = raw_pos + 1 if zero_based else raw_pos
# Guard against negative or zero after shift
if position <= 0:
position = idx + 1
else:
position = idx + 1
serial = chassis.get("entPhysicalSerialNum", "")
is_master = bool(device_serial and _norm_serial(serial) == device_serial)
member_data = {
"serial": serial,
"position": position,
"model": chassis.get("entPhysicalModelName", ""),
"name": chassis.get("entPhysicalName", ""),
"index": chassis.get("entPhysicalIndex"),
"description": chassis.get("entPhysicalDescr", ""),
"is_master": is_master,
}
# Generate suggested name if we have master name.
# position is already 1-based, so pass it directly (no +1).
if master_name:
member_data["suggested_name"] = _generate_vc_member_name(
master_name, position, serial=_norm_serial(serial), pattern=vc_name_pattern
)
else:
member_data["suggested_name"] = f"Member-{position}"
members.append(member_data)
# Sort by position
members.sort(key=lambda m: m["position"])
if zero_based:
logger.debug(
f"VC detection: corrected 0-based entPhysicalParentRelPos for device {device_id} "
f"(raw min={min(valid_positions)})"
)
master_member = next((m for m in members if m["is_master"]), None)
if master_member:
logger.info(
f"Detected stack with {len(members)} members for device {device_id}; "
f"master at position {master_member['position']} (serial {device_serial})"
)
else:
logger.info(
f"Detected stack with {len(members)} members for device {device_id}; "
f"master could not be identified by serial"
)
return {"is_stack": True, "member_count": len(members), "members": members}
except Exception as e:
logger.exception(f"Error detecting virtual chassis for device {device_id}: {e}")
return None
def _load_vc_member_name_pattern() -> str:
"""Load the VC member name pattern from settings, with fallback to default."""
from ..models import LibreNMSSettings
default = "-M{position}"
try:
settings = LibreNMSSettings.objects.order_by("pk").first()
if not settings:
return default
pattern = settings.vc_member_name_pattern
return pattern if isinstance(pattern, str) and pattern.strip() else default
except Exception as e:
logger.warning(f"Could not load VC member name pattern from settings: {e}. Using default.")
return default
def _generate_vc_member_name(master_name: str, position: int, serial: str = None, pattern: str = None) -> str:
"""
Generate name for VC member device using configured pattern from settings.
Args:
master_name: Name of the master/primary device
position: VC position number
serial: Optional serial number of the member device
pattern: Optional pre-loaded name pattern; if None, loaded from settings.
Pass a pre-loaded pattern when calling inside a loop to avoid
repeated DB queries.
Returns:
Generated member device name
Examples:
pattern="-M{position}" -> "switch01-M2"
pattern=" ({position})" -> "switch01 (2)"
pattern="-SW{position}" -> "switch01-SW2"
pattern=" [{serial}]" -> "switch01 [ABC123]"
"""
if pattern is None:
pattern = _load_vc_member_name_pattern()
# Prepare format variables
format_vars = {
"master_name": master_name,
"position": position,
"serial": serial or "",
}
# Apply pattern - pattern should be suffix/prefix, not full name
try:
formatted_suffix = pattern.format(**format_vars)
return f"{master_name}{formatted_suffix}"
except (KeyError, ValueError, IndexError) as e:
logger.error(f"Invalid placeholder in VC naming pattern '{pattern}': {e}. Using default.")
return f"{master_name}-M{position}"
def update_vc_member_suggested_names(vc_data: dict, master_name: str) -> dict:
"""
Regenerate suggested VC member names using the actual master device name.
This ensures preview shows accurate names after use_sysname and strip_domain
are applied to the master device name.
Args:
vc_data: Virtual chassis detection data dict
master_name: The actual name that will be used for master device in NetBox
Returns:
Updated vc_data dict with corrected suggested_name for each member
"""
if not vc_data or not vc_data.get("is_stack"):
return vc_data
# Load naming pattern once to avoid a DB query per member
vc_pattern = _load_vc_member_name_pattern()
for idx, member in enumerate(vc_data.get("members", [])):
# Positions are stored as 1-based (from entPhysicalParentRelPos or idx+1 fallback).
# Use them directly for name generation; only replace 0/negative with 1-based fallback.
raw_position = member.get("position", idx + 1)
try:
position = int(raw_position)
if position <= 0:
position = idx + 1
except (TypeError, ValueError):
position = idx + 1
member["position"] = position
member["suggested_name"] = _generate_vc_member_name(
master_name, position, serial=_norm_serial(member.get("serial")), pattern=vc_pattern
)
return vc_data
def _safe_pos(value) -> int | None:
"""Return int position or None if not parseable."""
try:
return int(value)
except (TypeError, ValueError):
return None
def _norm_serial(s) -> str:
"""Normalize serial: strip whitespace; treat '-' as absent."""
s = str(s or "").strip()
return "" if s == "-" else s
def _sync_module_bay_counter(device: Device) -> None:
"""Reconcile device module_bay_count with actual ModuleBay rows in the DB."""
try:
actual_count = device.modulebays.count()
if getattr(device, "module_bay_count", None) != actual_count:
Device.objects.filter(pk=device.pk).update(module_bay_count=actual_count)
device.module_bay_count = actual_count
except Exception as e:
logger.warning(
"Could not sync module_bay_count for device '%s': %s",
getattr(device, "name", "unknown"),
e,
)
def create_virtual_chassis_with_members(
master_device: Device, members_info: list, libre_device: dict, server_key: str | None = None
) -> VirtualChassis:
"""
Create Virtual Chassis and member devices from detection info.
This function creates a NetBox VirtualChassis with the master device
and all detected member devices, wrapped in a transaction for safety.
Args:
master_device: The imported device (becomes VC master)
members_info: List of member dicts from VC detection
libre_device: Original LibreNMS device data
Returns:
VirtualChassis: The created virtual chassis instance
Raises:
ValidationError: If member count validation fails
IntegrityError: If duplicate serials/names are detected
Exception: For other creation errors
Example members_info:
[
{'serial': 'ABC123', 'position': 0, 'model': 'C9300-48U', 'name': 'Switch 1'},
{'serial': 'ABC124', 'position': 1, 'model': 'C9300-48U', 'name': 'Switch 2'}
]
"""
# Save originals for in-memory rollback — transaction.atomic() rolls back DB but
# not in-memory model fields.
original_master_name = master_device.name
original_vc = master_device.virtual_chassis
original_vc_position = master_device.vc_position
# Find master's actual VC position from members_info.
# Priority: is_master flag (set during detection) → serial match → default 1.
_master_pos = 1
_master_member = next((m for m in members_info if m.get("is_master")), None)
if _master_member:
_found_pos = _safe_pos(_master_member.get("position"))
if _found_pos and _found_pos >= 1:
_master_pos = _found_pos
elif _norm_serial(master_device.serial):
for _m in members_info:
if _norm_serial(_m.get("serial")) == _norm_serial(master_device.serial):
_found_pos = _safe_pos(_m.get("position"))
if _found_pos and _found_pos >= 1:
_master_pos = _found_pos
break
try:
with transaction.atomic():
# Load naming pattern once to avoid a DB query per member
vc_pattern = _load_vc_member_name_pattern()
# Rename master device to include position 1 pattern
master_device_new_name = _generate_vc_member_name(
original_master_name, _master_pos, serial=_norm_serial(master_device.serial), pattern=vc_pattern
)
# Check if renamed master conflicts with existing device
if Device.objects.filter(name=master_device_new_name).exclude(pk=master_device.pk).exists():
logger.warning(
f"Cannot rename master to '{master_device_new_name}' - name already exists. "
f"Keeping original name '{original_master_name}'"
)
master_base_name = original_master_name
rename_master = False
else:
master_device.name = master_device_new_name
master_base_name = original_master_name
rename_master = True
# Create VC using original base name
vc_name = master_base_name
_device_id = libre_device.get("device_id") or master_device.pk
_domain_prefix = f"librenms-{server_key}" if server_key else "librenms"
vc = VirtualChassis.objects.create(
name=vc_name,
domain=f"{_domain_prefix}-{_device_id}",
)
# Update master device
master_device.virtual_chassis = vc
master_device.vc_position = _master_pos
save_fields = ["virtual_chassis", "vc_position"]
if rename_master:
save_fields.append("name")
master_device.save(update_fields=save_fields)
# Create member devices for remaining positions
position = _master_pos + 1 # Start after master position
used_positions = {_master_pos} # Master occupies its actual position
members_created = 0
for member in members_info:
# Normalize serial and position up front so all skip-checks and
# downstream logic use consistent values (strips whitespace and
# treats the sentinel "-" as "no serial").
serial = str(member.get("serial") or "").strip()
if serial == "-":
serial = ""
member_pos = _safe_pos(member.get("position"))
# Skip the master member — identified by is_master flag, serial match,
# or position match.
if member.get("is_master"):
continue
# Skip if this is the master's serial (only when both serials are non-empty)
if serial and serial == _norm_serial(master_device.serial):
continue
# Skip blank-serial entries that represent the master slot by position
if (
not serial
and member_pos is not None
and master_device.vc_position is not None
and member_pos == master_device.vc_position
):
continue
member_rack = master_device.rack
member_location = master_device.location or (
member_rack.location if member_rack and member_rack.location else None
)
# Check for duplicate serial
if serial and Device.objects.filter(serial=serial).exists():
logger.warning(f"Device with serial '{serial}' already exists, skipping VC member creation")
continue
# Prefer the discovered SNMP position; fall back to sequential counter.
# member_pos was normalized via _safe_pos() above; 0 is not a valid vc_position.
discovered_pos = member_pos if (member_pos is not None and member_pos >= 1) else None
# If discovered_pos is already taken by another member, treat as absent.
if discovered_pos is not None and discovered_pos in used_positions:
discovered_pos = None
# Consume next free sequential slot when no valid discovered_pos.
if discovered_pos is None:
while position in used_positions:
position += 1
chosen_pos = position
position += 1
else:
chosen_pos = discovered_pos
# Advance sequential counter past chosen position.
position = max(position, chosen_pos + 1)
used_positions.add(chosen_pos)
member_name = _generate_vc_member_name(master_base_name, chosen_pos, serial=serial, pattern=vc_pattern)
# Check for duplicate name
if Device.objects.filter(name=member_name).exists():
logger.warning(f"Device with name '{member_name}' already exists, skipping VC member creation")
continue
Device.objects.create(
name=member_name,
device_type=master_device.device_type,
role=master_device.role,
site=master_device.site,
location=member_location,
rack=member_rack,
platform=master_device.platform,
serial=serial,
virtual_chassis=vc,
vc_position=chosen_pos,
comments=f"VC member (LibreNMS: {member.get('name', 'Unknown')})\n"
f"Auto-created from stack inventory",
)
members_created += 1
# Validate member count
# Validate member count — exclude master-slot entries with blank serials
expected_members = len(
[
m
for m in members_info
if not (
_norm_serial(m.get("serial"))
and _norm_serial(m.get("serial")) == _norm_serial(master_device.serial)
)
and not (
not _norm_serial(m.get("serial"))
and m.get("position") is not None
and master_device.vc_position is not None
and _safe_pos(m["position"]) == master_device.vc_position
)
]
)
if members_created < expected_members:
logger.warning(
f"Created {members_created} members but expected {expected_members}. "
"Some members may have been skipped due to duplicates."
)
# Assign VC master only after all members are attached to avoid
# NetBox's create-time auto-master signal changing order/state.
vc.master = master_device
vc.save(update_fields=["master"])
_sync_module_bay_counter(master_device)
logger.info(
f"Created Virtual Chassis '{vc.name}' with {vc.members.count()} total members "
f"(1 master + {members_created} additional)"
)
return vc
except Exception as e:
master_device.name = original_master_name
master_device.virtual_chassis = original_vc
master_device.vc_position = original_vc_position
logger.error(
f"Virtual Chassis creation failed for device {original_master_name}: {e}",
exc_info=True,
)
raise

View File

@@ -0,0 +1,257 @@
"""Virtual machine creation and import operations."""
import logging
from dcim.models import DeviceRole
from django.db import transaction
from django.utils import timezone
from virtualization.models import Cluster
from ..librenms_api import LibreNMSAPI
from .bulk_import import _is_job_cancelled
from .device_operations import _determine_device_name, fetch_device_with_cache, validate_device_for_import
from .permissions import require_permissions
logger = logging.getLogger(__name__)
def create_vm_from_librenms(
libre_device: dict,
validation: dict,
server_key: str = "default",
use_sysname: bool = True,
strip_domain: bool = False,
role=None,
):
"""
Create a NetBox VirtualMachine from LibreNMS device data.
Args:
libre_device: Device data from LibreNMS
validation: Validation result from validate_device_for_import with import_as_vm=True
use_sysname: If True, prefer sysName; if False, use hostname
server_key: LibreNMS server key used to store the librenms_id custom field
Returns:
Created VirtualMachine instance
Raises:
Exception if VM cannot be created
"""
from virtualization.models import VirtualMachine
if not validation["can_import"]:
raise ValueError(f"VM cannot be imported: {', '.join(validation['issues'])}")
# Extract matched objects from validation
cluster = validation["cluster"]["cluster"]
platform = validation["platform"].get("platform")
role = role if role is not None else validation.get("device_role", {}).get("role")
# Determine VM name - use pre-computed name if available (handles strip_domain),
# falling back to the validated resolved_name before recomputing from raw fields.
vm_name = libre_device.get("_computed_name") or validation.get("resolved_name")
if not vm_name:
vm_name = _determine_device_name(
libre_device,
use_sysname=use_sysname,
strip_domain=strip_domain,
device_id=libre_device.get("device_id"),
)
# Generate import timestamp comment
import_time = timezone.now().strftime("%Y-%m-%d %H:%M:%S %Z")
# Validate device_id before creating the VM so a missing/invalid value
# never leaves a VM without a librenms_id (partial persistence).
raw_device_id = libre_device["device_id"]
if isinstance(raw_device_id, bool):
raise ValueError(f"device_id is a boolean ({raw_device_id!r}); expected an integer")
librenms_device_id = int(raw_device_id)
from ..utils import set_librenms_device_id
# Create the VM and assign its LibreNMS ID atomically so a failure in
# set_librenms_device_id never leaves a VM without a mapping.
with transaction.atomic():
vm = VirtualMachine.objects.create(
name=vm_name,
cluster=cluster,
role=role,
platform=platform,
comments=f"Imported from LibreNMS (device_id={librenms_device_id}) by netbox-librenms-plugin on {import_time}",
)
set_librenms_device_id(vm, librenms_device_id, server_key)
vm.save()
logger.info(f"Created VM {vm.name} (ID: {vm.pk}) from LibreNMS device {libre_device['device_id']}")
return vm
def bulk_import_vms(
vm_imports: dict[int, dict[str, int]],
api: LibreNMSAPI,
sync_options: dict = None,
libre_devices_cache: dict = None,
job=None,
user=None,
) -> dict:
"""
Import multiple LibreNMS devices as VMs in NetBox.
Handles validation, cluster/role assignment, name determination,
and VM creation. Supports both synchronous and background job execution.
This function consolidates VM import logic that was previously duplicated
in BulkImportDevicesView and ImportDevicesJob, ensuring consistent behavior
across synchronous and background import paths.
Args:
vm_imports: Dict mapping device_id to {"cluster_id": int, "device_role_id": int}
api: LibreNMSAPI instance for device fetching
sync_options: Optional dict with use_sysname, strip_domain settings
libre_devices_cache: Optional pre-fetched device data cache
job: Optional JobRunner instance for background job logging/cancellation
user: User performing the import (for permission checks). If job is provided,
user is extracted from job.job.user if not explicitly passed.
Returns:
Dict with keys:
- success: List of {"device_id": int, "device": VM, "message": str}
- failed: List of {"device_id": int, "error": str}
- skipped: List of {"device_id": int, "reason": str}
Raises:
PermissionDenied: If user lacks required permissions
Example:
>>> # Synchronous import from view
>>> vm_imports = {123: {"cluster_id": 5, "device_role_id": 2}}
>>> result = bulk_import_vms(vm_imports, api, sync_options, user=request.user)
>>> print(f"Created {len(result['success'])} VMs")
>>>
>>> # Background job import
>>> result = bulk_import_vms(vm_imports, api, sync_options, cache, job=self)
"""
from netbox_librenms_plugin.import_validation_helpers import (
apply_cluster_to_validation,
apply_role_to_validation,
)
# Extract user from job if not explicitly provided
if user is None and job is not None:
user = getattr(job.job, "user", None)
# Check permissions at start of bulk operation
require_permissions(user, ["virtualization.add_virtualmachine"], "import VMs")
result = {"success": [], "failed": [], "skipped": []}
vm_ids = list(vm_imports.keys())
# Use job logger if available, otherwise standard logger
log = job.logger if job else logger
for idx, vm_id in enumerate(vm_ids, start=1):
# Check for job cancellation before first VM and every 5 thereafter
if job and (idx == 1 or idx % 5 == 0) and _is_job_cancelled(job):
log.warning(f"Job cancelled at VM {idx} of {len(vm_ids)}")
break
log.info(f"Processing VM {idx} of {len(vm_ids)}")
try:
# Fetch device data (uses cache helper)
libre_device = fetch_device_with_cache(vm_id, api, api.server_key, libre_devices_cache)
if not libre_device:
result["failed"].append(
{
"device_id": vm_id,
"error": f"Device {vm_id} not found in LibreNMS",
}
)
log.error(f"Device {vm_id} not found in LibreNMS")
continue
# Validate as VM
use_sysname_opt = sync_options.get("use_sysname", True) if sync_options else True
strip_domain_opt = sync_options.get("strip_domain", False) if sync_options else False
validation = validate_device_for_import(
libre_device,
import_as_vm=True,
api=api,
use_sysname=use_sysname_opt,
strip_domain=strip_domain_opt,
server_key=api.server_key,
)
# Check if VM already exists
if validation.get("existing_device"):
result["skipped"].append(
{
"device_id": vm_id,
"reason": f"VM already exists: {validation['existing_device'].name}",
}
)
log.info(f"VM already exists: {validation['existing_device'].name}")
continue
# Apply manual cluster and role selections
vm_mappings = vm_imports[vm_id]
cluster_id = vm_mappings.get("cluster_id")
role_id = vm_mappings.get("device_role_id")
if cluster_id:
cluster = Cluster.objects.filter(id=cluster_id).first()
if cluster:
apply_cluster_to_validation(validation, cluster)
else:
result["failed"].append(
{"device_id": vm_id, "error": f"Selected cluster (id={cluster_id}) no longer exists"}
)
continue
role = None
if role_id:
role = DeviceRole.objects.filter(id=role_id).first()
if role:
apply_role_to_validation(validation, role, is_vm=True)
else:
result["failed"].append(
{"device_id": vm_id, "error": f"Selected role (id={role_id}) no longer exists"}
)
continue
# Determine VM name
vm_name = _determine_device_name(
libre_device,
use_sysname=use_sysname_opt,
strip_domain=strip_domain_opt,
device_id=vm_id,
)
# Update validation with computed name
libre_device["_computed_name"] = vm_name
# Create VM
vm = create_vm_from_librenms(
libre_device,
validation,
use_sysname=use_sysname_opt,
strip_domain=strip_domain_opt,
server_key=api.server_key,
)
result["success"].append(
{
"device_id": vm_id,
"device": vm,
"message": f"VM {vm.name} created successfully",
}
)
log.info(f"Successfully imported VM {vm.name} (ID: {vm_id})")
except Exception as vm_error:
log.error(f"Failed to import VM {vm_id}: {vm_error}", exc_info=True)
result["failed"].append({"device_id": vm_id, "error": str(vm_error)})
return result

View File

@@ -0,0 +1,168 @@
"""
Helper functions for validation state mutation during import workflow.
These functions centralize the logic for updating validation dictionaries
when users select roles, clusters, or racks during the device import process.
"""
import logging
logger = logging.getLogger(__name__)
def fetch_model_by_id(model_class, pk):
"""
Generic helper to fetch a model instance by primary key.
Args:
model_class: Django model class (e.g., DeviceRole, Cluster, Rack)
pk: Primary key value (int, str, or None)
Returns:
Model instance if found and valid, None otherwise
Example:
>>> from dcim.models import DeviceRole
>>> role = fetch_model_by_id(DeviceRole, "5")
>>> role.name
'Router'
"""
if pk is None:
return None
try:
return model_class.objects.get(pk=int(pk))
except (model_class.DoesNotExist, ValueError, TypeError):
return None
def extract_device_selections(request, device_id):
"""
Extract cluster, role, and rack selections from request POST/GET data.
Args:
request: Django request object
device_id: LibreNMS device ID
Returns:
dict with keys: cluster_id, role_id, rack_id (all may be None)
Example:
>>> selections = extract_device_selections(request, 1234)
>>> selections
{'cluster_id': None, 'role_id': '5', 'rack_id': '12'}
"""
# Check both POST and GET data (different views use different methods)
data_source = request.POST if request.method == "POST" else request.GET
return {
"cluster_id": data_source.get(f"cluster_{device_id}"),
"role_id": data_source.get(f"role_{device_id}"),
"rack_id": data_source.get(f"rack_{device_id}"),
}
def apply_role_to_validation(validation: dict, role, is_vm: bool = False) -> None:
"""
Update validation state after device/VM role selection.
Args:
validation: Validation dict from validate_device_for_import()
role: DeviceRole instance selected by user
is_vm: True if importing as VM, False for device
Modifies validation dict in-place:
- Sets device_role["found"] = True
- Sets device_role["role"] = role
- Removes "role" related issues
- Recalculates can_import and is_ready flags
"""
validation["device_role"]["found"] = True
validation["device_role"]["role"] = role
remove_validation_issue(validation, "role")
recalculate_validation_status(validation, is_vm)
def apply_cluster_to_validation(validation: dict, cluster) -> None:
"""
Update validation state after cluster selection (VM import only).
Args:
validation: Validation dict from validate_device_for_import()
cluster: Cluster instance selected by user
Modifies validation dict in-place:
- Sets cluster["found"] = True
- Sets cluster["cluster"] = cluster
- Removes "cluster" related issues
- Recalculates can_import and is_ready flags (as VM)
"""
validation["cluster"]["found"] = True
validation["cluster"]["cluster"] = cluster
remove_validation_issue(validation, "cluster")
recalculate_validation_status(validation, is_vm=True)
def apply_rack_to_validation(validation: dict, rack) -> None:
"""
Update validation state after rack selection (device import only).
Args:
validation: Validation dict from validate_device_for_import()
rack: Rack instance selected by user
Modifies validation dict in-place:
- Sets rack["found"] = True
- Sets rack["rack"] = rack
Note: Rack is optional, so this doesn't affect can_import/is_ready.
"""
validation.setdefault("rack", {})
validation["rack"]["found"] = True
validation["rack"]["rack"] = rack
def remove_validation_issue(validation: dict, keyword: str) -> None:
"""
Remove validation issues containing the specified keyword.
Args:
validation: Validation dict
keyword: Keyword to search for in issue messages (case-insensitive)
Example:
>>> remove_validation_issue(validation, "role")
# Removes "Device role must be manually selected before import"
"""
validation["issues"] = [issue for issue in validation["issues"] if keyword.lower() not in issue.lower()]
def recalculate_validation_status(validation: dict, is_vm: bool = False) -> None:
"""
Recalculate can_import and is_ready flags based on current validation state.
Args:
validation: Validation dict
is_vm: True if importing as VM, False for device
Updates:
- can_import: True if no blocking issues remain
- is_ready: True if can_import AND all required fields are found
Required fields for devices:
- site, device_type, device_role
Required fields for VMs:
- cluster
"""
validation["can_import"] = len(validation["issues"]) == 0
if is_vm:
validation["is_ready"] = validation["can_import"] and validation["cluster"]["found"]
else:
validation["is_ready"] = (
validation["can_import"]
and validation["site"]["found"]
and validation["device_type"]["found"]
and validation["device_role"]["found"]
)

View File

@@ -0,0 +1,275 @@
"""
Background jobs for LibreNMS plugin.
This module provides background job implementations for long-running operations
such as device filtering with Virtual Chassis detection.
"""
import logging
from netbox.jobs import JobRunner
logger = logging.getLogger(__name__)
class FilterDevicesJob(JobRunner):
"""
Background job for processing LibreNMS device filters with VC detection.
Background jobs provide several benefits over synchronous processing:
- Active cancellation via NetBox Jobs interface
- Browser remains responsive (no "page loading" state)
- Job progress tracked in NetBox Jobs table
- Results persist in cache for later retrieval
Users control background job execution via the "Run as background job" checkbox
in the filter form. When enabled, the job runs asynchronously; when disabled,
filtering runs synchronously.
Note: Both synchronous and background processing complete once started,
even if the user navigates away. The key difference is cancellation ability
and browser responsiveness.
Results are cached individually per device to avoid exceeding job data size limits.
"""
class Meta:
"""Meta options for FilterDevicesJob."""
name = "LibreNMS Device Filter"
def run(
self,
filters,
vc_detection_enabled,
clear_cache,
show_disabled,
exclude_existing=False,
server_key=None,
use_sysname=True,
strip_domain=False,
**kwargs,
):
"""
Execute filter processing in background.
Logs job start, completion, and any early termination events.
Args:
filters: Dict with location, type, os, hostname, sysname keys
vc_detection_enabled: Whether to detect virtual chassis
clear_cache: Whether to force cache refresh
show_disabled: Whether to include disabled devices
exclude_existing: Whether to exclude devices that already exist in NetBox
server_key: Optional LibreNMS server key for multi-server setups
use_sysname: If True, prefer sysName over hostname for device name resolution
strip_domain: If True, strip domain suffix from device names
**kwargs: Additional job parameters
"""
from netbox_librenms_plugin.import_utils import process_device_filters
from netbox_librenms_plugin.librenms_api import LibreNMSAPI
self.logger.info("Starting LibreNMS device filter job")
self.logger.info(f"Filters: {filters}")
self.logger.info(f"VC detection: {vc_detection_enabled}")
self.logger.info(f"Clear cache: {clear_cache}")
self.logger.info(f"Show disabled: {show_disabled}")
if exclude_existing:
self.logger.info("Excluding existing devices")
if server_key:
self.logger.info(f"Using LibreNMS server: {server_key}")
# Initialize API client
api = LibreNMSAPI(server_key=server_key)
self.logger.info(f"LibreNMS API initialized (cache timeout: {api.cache_timeout}s)")
# Process filters using shared function
validated_devices = process_device_filters(
api=api,
filters=filters,
vc_detection_enabled=vc_detection_enabled,
clear_cache=clear_cache,
show_disabled=show_disabled,
exclude_existing=exclude_existing,
job=self,
use_sysname=use_sysname,
strip_domain=strip_domain,
)
# Store device IDs for result retrieval
# Note: Validated devices are cached with shared keys by process_device_filters
device_ids = [device["device_id"] for device in validated_devices]
# Track cache timestamp for frontend expiration warnings
from datetime import datetime, timezone
cached_at = datetime.now(timezone.utc).isoformat()
# Store only metadata in job data (not the full device list)
# Devices are retrieved via shared cache keys in _load_job_results
self.job.data = {
"device_ids": device_ids,
"total_processed": len(validated_devices),
"filters": filters,
"server_key": api.server_key,
"vc_detection_enabled": vc_detection_enabled,
"use_sysname": use_sysname,
"strip_domain": strip_domain,
"cache_timeout": api.cache_timeout,
"cached_at": cached_at,
"completed": True,
}
self.job.save(update_fields=["data"])
self.logger.info(
f"Job completed successfully. Processed {len(validated_devices)} devices. "
f"Results available via shared cache for {api.cache_timeout} seconds."
)
class ImportDevicesJob(JobRunner):
"""
Background job for importing LibreNMS devices to NetBox.
Handles bulk device/VM imports in the background to keep browser responsive.
Benefits:
- Active cancellation via NetBox Jobs interface
- Browser remains responsive during large imports
- Job progress tracked with device count logging
- Errors collected per device without stopping entire import
Users control background job execution via the "Run as background job" checkbox
in the import confirmation modal. When enabled, the job runs asynchronously;
when disabled, imports run synchronously.
Results stored in job.data with structure:
{
"imported_device_pks": [1, 2, 3], # NetBox Device PKs
"imported_vm_pks": [10, 11], # NetBox VirtualMachine PKs
"total": 5,
"success_count": 4,
"failed_count": 1,
"skipped_count": 0,
"errors": [{"device_id": 123, "error": "..."}]
}
"""
class Meta:
"""Meta options for ImportDevicesJob."""
name = "LibreNMS Device Import"
def run(
self,
device_ids,
vm_imports,
server_key=None,
sync_options=None,
manual_mappings_per_device=None,
libre_devices_cache=None,
**kwargs,
):
"""
Execute device/VM imports in background.
Args:
device_ids: List of LibreNMS device IDs to import as Devices
vm_imports: Dict mapping device_id to cluster/role info for VM imports
server_key: Optional LibreNMS server key for multi-server setups
sync_options: Dict with sync_interfaces, sync_cables, sync_ips,
use_sysname, strip_domain, and vc_detection_enabled
manual_mappings_per_device: Dict mapping device_id to manual_mappings dict
libre_devices_cache: Optional dict mapping device_id to pre-fetched device data
**kwargs: Additional job parameters
"""
from netbox_librenms_plugin.import_utils import (
bulk_import_devices_shared,
)
from netbox_librenms_plugin.librenms_api import LibreNMSAPI
total_count = len(device_ids) + len(vm_imports)
self.logger.info(f"Starting LibreNMS import job for {total_count} devices/VMs")
self.logger.info(f"Device imports: {len(device_ids)}, VM imports: {len(vm_imports)}")
if server_key:
self.logger.info(f"Using LibreNMS server: {server_key}")
# Initialize API client
api = LibreNMSAPI(server_key=server_key)
# Import devices using shared function with job context
device_result = {
"success": [],
"failed": [],
"skipped": [],
"virtual_chassis_created": 0,
}
if device_ids:
self.logger.info(f"Importing {len(device_ids)} devices...")
device_result = bulk_import_devices_shared(
device_ids=device_ids,
server_key=api.server_key,
sync_options=sync_options,
manual_mappings_per_device=manual_mappings_per_device,
libre_devices_cache=libre_devices_cache,
job=self, # Pass job context for logging and cancellation
user=self.job.user, # Pass user for permission checks
)
# Import VMs
vm_result = {"success": [], "failed": [], "skipped": []}
if vm_imports:
self.logger.info(f"Importing {len(vm_imports)} VMs...")
from netbox_librenms_plugin.import_utils import bulk_import_vms
vm_result = bulk_import_vms(
vm_imports, api, sync_options, libre_devices_cache, job=self, user=self.job.user
)
# Combine results — partition device_result successes by model type since
# bulk_import_devices_shared() may return VirtualMachine objects when import_as_vm=True.
device_successes = []
vm_successes = list(vm_result.get("success", []))
for item in device_result.get("success", []):
obj = item.get("device")
if not obj:
continue
if obj._meta.model_name == "virtualmachine":
vm_successes.append(item)
else:
device_successes.append(item)
imported_device_pks = [item["device"].pk for item in device_successes]
imported_vm_pks = [item["device"].pk for item in vm_successes]
# Also store LibreNMS device IDs for re-rendering table rows
imported_libre_device_ids = [item["device_id"] for item in device_successes]
imported_libre_vm_ids = [item["device_id"] for item in vm_successes]
success_count = len(device_result.get("success", [])) + len(vm_result.get("success", []))
failed_count = len(device_result.get("failed", [])) + len(vm_result.get("failed", []))
skipped_count = len(device_result.get("skipped", [])) + len(vm_result.get("skipped", []))
all_errors = device_result.get("failed", []) + vm_result.get("failed", [])
# Store results in job.data
self.job.data = {
"imported_device_pks": imported_device_pks,
"imported_vm_pks": imported_vm_pks,
"imported_libre_device_ids": imported_libre_device_ids,
"imported_libre_vm_ids": imported_libre_vm_ids,
"server_key": api.server_key,
"total": total_count,
"success_count": success_count,
"failed_count": failed_count,
"skipped_count": skipped_count,
"virtual_chassis_created": device_result.get("virtual_chassis_created", 0),
"errors": all_errors,
"completed": True,
}
self.job.save(update_fields=["data"])
self.logger.info(
f"Import job completed. Success: {success_count}, Failed: {failed_count}, Skipped: {skipped_count}"
)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,23 @@
# Generated by Django 5.0.9 on 2024-09-19 10:17
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = []
operations = [
migrations.CreateModel(
name="InterfaceTypeMapping",
fields=[
(
"id",
models.BigAutoField(auto_created=True, primary_key=True, serialize=False),
),
("librenms_type", models.CharField(max_length=100, unique=True)),
("netbox_type", models.CharField(default="other", max_length=50)),
],
),
]

View File

@@ -0,0 +1,35 @@
# Generated by Django 5.0.9 on 2024-09-19 11:14
import taggit.managers
import utilities.json
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("extras", "0121_customfield_related_object_filter"),
("netbox_librenms_plugin", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="interfacetypemapping",
name="created",
field=models.DateTimeField(auto_now_add=True, null=True),
),
migrations.AddField(
model_name="interfacetypemapping",
name="custom_field_data",
field=models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder),
),
migrations.AddField(
model_name="interfacetypemapping",
name="last_updated",
field=models.DateTimeField(auto_now=True, null=True),
),
migrations.AddField(
model_name="interfacetypemapping",
name="tags",
field=taggit.managers.TaggableManager(through="extras.TaggedItem", to="extras.Tag"),
),
]

View File

@@ -0,0 +1,26 @@
# Generated by Django 5.0.9 on 2024-10-17 10:19
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("netbox_librenms_plugin", "0002_interfacetypemapping_created_and_more"),
]
operations = [
migrations.AddField(
model_name="interfacetypemapping",
name="librenms_speed",
field=models.BigIntegerField(blank=True, null=True),
),
migrations.AlterField(
model_name="interfacetypemapping",
name="librenms_type",
field=models.CharField(max_length=100),
),
migrations.AlterUniqueTogether(
name="interfacetypemapping",
unique_together={("librenms_type", "librenms_speed")},
),
]

View File

@@ -0,0 +1,46 @@
# Generated by Django 5.1.4 on 2025-08-07 12:50
import taggit.managers
import utilities.json
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("extras", "0122_charfield_null_choices"),
("netbox_librenms_plugin", "0003_interfacetypemapping_librenms_speed_and_more"),
]
operations = [
migrations.CreateModel(
name="LibreNMSSettings",
fields=[
(
"id",
models.BigAutoField(auto_created=True, primary_key=True, serialize=False),
),
("created", models.DateTimeField(auto_now_add=True, null=True)),
("last_updated", models.DateTimeField(auto_now=True, null=True)),
(
"custom_field_data",
models.JSONField(
blank=True,
default=dict,
encoder=utilities.json.CustomFieldJSONEncoder,
),
),
(
"selected_server",
models.CharField(default="default", max_length=100),
),
(
"tags",
taggit.managers.TaggableManager(through="extras.TaggedItem", to="extras.Tag"),
),
],
options={
"verbose_name": "LibreNMS Settings",
"verbose_name_plural": "LibreNMS Settings",
},
),
]

View File

@@ -0,0 +1,28 @@
# Generated by Django 5.1.4 on 2025-08-07 13:07
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("netbox_librenms_plugin", "0004_librenmssettings"),
]
operations = [
migrations.RemoveField(
model_name="librenmssettings",
name="created",
),
migrations.RemoveField(
model_name="librenmssettings",
name="custom_field_data",
),
migrations.RemoveField(
model_name="librenmssettings",
name="last_updated",
),
migrations.RemoveField(
model_name="librenmssettings",
name="tags",
),
]

View File

@@ -0,0 +1,20 @@
# Generated migration for adding description field to InterfaceTypeMapping
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("netbox_librenms_plugin", "0005_remove_librenmssettings_created_and_more"),
]
operations = [
migrations.AddField(
model_name="interfacetypemapping",
name="description",
field=models.TextField(
blank=True,
help_text="Optional description or notes about this interface type mapping",
),
),
]

View File

@@ -0,0 +1,21 @@
# Generated migration for adding vc_member_name_pattern field to LibreNMSSettings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("netbox_librenms_plugin", "0006_interfacetypemapping_description"),
]
operations = [
migrations.AddField(
model_name="librenmssettings",
name="vc_member_name_pattern",
field=models.CharField(
default="-M{position}",
help_text="Pattern for naming virtual chassis member devices. Available placeholders: {master_name}, {position}, {serial}. Example: '-M{position}' results in 'switch01-M2'",
max_length=100,
),
),
]

View File

@@ -0,0 +1,28 @@
# Generated migration for adding import default settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("netbox_librenms_plugin", "0007_librenmssettings_vc_member_name_pattern"),
]
operations = [
migrations.AddField(
model_name="librenmssettings",
name="use_sysname_default",
field=models.BooleanField(
default=True,
help_text="Use SNMP sysName instead of LibreNMS hostname when importing devices",
),
),
migrations.AddField(
model_name="librenmssettings",
name="strip_domain_default",
field=models.BooleanField(
default=False,
help_text="Remove domain suffix from device names during import",
),
),
]

View File

@@ -0,0 +1,78 @@
"""
Convert the ``librenms_id`` custom field type to JSON.
Multi-server support stores ``librenms_id`` as a JSON dict
(``{"server_key": device_id, …}``). Installations created before this
change will have the custom field defined as type *integer* (documented)
or *text* (some users created it with the wrong type). This migration
converts any non-JSON type to JSON so that NetBox validation accepts the
new dict format.
Existing bare-integer **values** on devices/VMs/interfaces are left
untouched — they are migrated to the dict format on a per-object basis
through the admin UI / API (see ``migrate_legacy_librenms_id`` in utils).
"""
from django.db import migrations
def _convert_librenms_id_to_json(apps, schema_editor):
db_alias = schema_editor.connection.alias
CustomField = apps.get_model("extras", "CustomField")
try:
cf = CustomField.objects.using(db_alias).get(name="librenms_id")
except CustomField.DoesNotExist:
# Custom field hasn't been created yet — nothing to convert.
return
if cf.type != "json":
cf.type = "json"
cf.save(using=db_alias, update_fields=["type"])
def _revert_librenms_id_to_integer(apps, schema_editor):
db_alias = schema_editor.connection.alias
CustomField = apps.get_model("extras", "CustomField")
try:
cf = CustomField.objects.using(db_alias).get(name="librenms_id")
except CustomField.DoesNotExist:
return
# Prevent unsafe downgrade if JSON-scoped values already exist.
models_to_check = [
("dcim", "Device"),
("virtualization", "VirtualMachine"),
("dcim", "Interface"),
("virtualization", "VMInterface"),
]
for app_label, model_name in models_to_check:
Model = apps.get_model(app_label, model_name)
for value in (
Model.objects.using(db_alias)
.exclude(custom_field_data__librenms_id=None)
.values_list("custom_field_data__librenms_id", flat=True)
.iterator()
):
if isinstance(value, dict):
raise RuntimeError(
"Cannot reverse librenms_id CustomField to integer: "
"JSON-scoped values already exist. Migrate them back to "
"bare integers first."
)
if cf.type == "json":
cf.type = "integer"
cf.save(using=db_alias, update_fields=["type"])
class Migration(migrations.Migration):
dependencies = [
("extras", "0001_initial"),
("netbox_librenms_plugin", "0008_librenmssettings_import_defaults"),
]
operations = [
migrations.RunPython(
code=_convert_librenms_id_to_json,
reverse_code=_revert_librenms_id_to_integer,
),
]

View File

@@ -0,0 +1,76 @@
from dcim.choices import InterfaceTypeChoices
from django.db import models
from django.urls import reverse
from netbox.models import NetBoxModel
class LibreNMSSettings(models.Model):
"""
Model to store LibreNMS plugin settings, specifically which server to use
when multiple servers are configured.
"""
selected_server = models.CharField(
max_length=100,
default="default",
help_text="The key of the selected LibreNMS server from configuration",
)
vc_member_name_pattern = models.CharField(
max_length=100,
default="-M{position}",
help_text="Pattern for naming virtual chassis member devices. "
"Available placeholders: {position}, {serial}. "
"Example: '-M{position}' results in 'switch01-M2'",
)
use_sysname_default = models.BooleanField(
default=True,
help_text="Use SNMP sysName instead of LibreNMS hostname when importing devices",
)
strip_domain_default = models.BooleanField(
default=False,
help_text="Remove domain suffix from device names during import",
)
class Meta:
"""Meta options for LibreNMSSettings."""
verbose_name = "LibreNMS Settings"
verbose_name_plural = "LibreNMS Settings"
def get_absolute_url(self):
"""Return the URL for the settings page."""
return reverse("plugins:netbox_librenms_plugin:settings")
def __str__(self):
return f"LibreNMS Settings - Server: {self.selected_server}"
class InterfaceTypeMapping(NetBoxModel):
"""Map LibreNMS interface types and speeds to NetBox interface types."""
librenms_type = models.CharField(max_length=100)
netbox_type = models.CharField(
max_length=50,
choices=InterfaceTypeChoices,
default=InterfaceTypeChoices.TYPE_OTHER,
)
librenms_speed = models.BigIntegerField(null=True, blank=True)
description = models.TextField(
blank=True,
help_text="Optional description or notes about this interface type mapping",
)
def get_absolute_url(self):
"""Return the URL for this mapping's detail page."""
return reverse("plugins:netbox_librenms_plugin:interfacetypemapping_detail", args=[self.pk])
class Meta:
"""Meta options for InterfaceTypeMapping."""
unique_together = ["librenms_type", "librenms_speed"]
def __str__(self):
return f"{self.librenms_type} + {self.librenms_speed} -> {self.netbox_type}"

View File

@@ -0,0 +1,67 @@
from netbox.plugins import PluginMenu, PluginMenuButton, PluginMenuItem
from netbox_librenms_plugin.constants import PERM_VIEW_PLUGIN
menu = PluginMenu(
label="LibreNMS",
icon_class="mdi mdi-network",
groups=(
(
"Settings",
(
PluginMenuItem(
link="plugins:netbox_librenms_plugin:settings",
link_text="Plugin Settings",
permissions=[PERM_VIEW_PLUGIN],
),
PluginMenuItem(
link="plugins:netbox_librenms_plugin:interfacetypemapping_list",
link_text="Interface Mappings",
permissions=[PERM_VIEW_PLUGIN],
buttons=(
PluginMenuButton(
link="plugins:netbox_librenms_plugin:interfacetypemapping_add",
title="Add",
icon_class="mdi mdi-plus-thick",
),
PluginMenuButton(
link="plugins:netbox_librenms_plugin:interfacetypemapping_bulk_import",
title="Import",
icon_class="mdi mdi-upload",
),
),
),
),
),
(
"Import",
(
PluginMenuItem(
link="plugins:netbox_librenms_plugin:librenms_import",
link_text="LibreNMS Import",
permissions=[PERM_VIEW_PLUGIN],
),
),
),
(
"Status Check",
(
PluginMenuItem(
link="plugins:netbox_librenms_plugin:site_location_sync",
link_text="Site & Location Sync",
permissions=[PERM_VIEW_PLUGIN],
),
PluginMenuItem(
link="plugins:netbox_librenms_plugin:device_status_list",
link_text="Device Status",
permissions=[PERM_VIEW_PLUGIN],
),
PluginMenuItem(
link="plugins:netbox_librenms_plugin:vm_status_list",
link_text="VM Status",
permissions=[PERM_VIEW_PLUGIN],
),
),
),
),
)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,56 @@
from django.urls import reverse
from django.utils.safestring import mark_safe
from django_tables2 import Column
from virtualization.models import VirtualMachine
from virtualization.tables import VirtualMachineTable
class VMStatusTable(VirtualMachineTable):
"""
Table for displaying virtual machine LibreNMS status.
"""
librenms_status = Column(
verbose_name="LibreNMS Status",
empty_values=(),
accessor="librenms_status",
orderable=False,
)
def render_librenms_status(self, value, record):
"""Render the LibreNMS status with styles based on sync status."""
sync_url = reverse(
"plugins:netbox_librenms_plugin:vm_librenms_sync",
kwargs={"pk": record.pk},
)
if value:
status = '<span class="text-success"><i class="mdi mdi-check-circle"></i> Synced</span>'
elif value is False:
status = '<span class="text-danger"><i class="mdi mdi-close-circle"></i> Not Found</span>'
else:
status = '<span class="text-secondary"><i class="mdi mdi-help-circle"></i> Unknown</span>'
return mark_safe(f'<a href="{sync_url}">{status}</a>')
class Meta(VirtualMachineTable.Meta):
"""Meta options for VMStatusTable."""
model = VirtualMachine
fields = (
"pk",
"name",
"status",
"cluster",
"cluster_type",
"cluster_group",
"librenms_status",
)
default_columns = (
"name",
"status",
"cluster",
"cluster_type",
"cluster_group",
"librenms_status",
)

View File

@@ -0,0 +1,21 @@
from .cables import LibreNMSCableTable
from .device_status import DeviceStatusTable
from .interfaces import LibreNMSInterfaceTable, LibreNMSVMInterfaceTable, VCInterfaceTable
from .ipaddresses import IPAddressTable
from .locations import SiteLocationSyncTable
from .mappings import InterfaceTypeMappingTable
from .vlans import LibreNMSVLANTable
from .VM_status import VMStatusTable
__all__ = [
"DeviceStatusTable",
"InterfaceTypeMappingTable",
"IPAddressTable",
"LibreNMSCableTable",
"LibreNMSInterfaceTable",
"LibreNMSVLANTable",
"LibreNMSVMInterfaceTable",
"SiteLocationSyncTable",
"VCInterfaceTable",
"VMStatusTable",
]

View File

@@ -0,0 +1,161 @@
import django_tables2 as tables
from django.utils.html import escape, format_html
from django.utils.safestring import mark_safe
from netbox.tables.columns import ToggleColumn
from utilities.paginator import EnhancedPaginator
from netbox_librenms_plugin.utils import (
get_table_paginate_count,
get_virtual_chassis_member,
)
class LibreNMSCableTable(tables.Table):
"""
Table for displaying LibreNMS cable data.
"""
selection = ToggleColumn(
accessor="local_port_id",
orderable=False,
visible=True,
attrs={"td": {"data-col": "selection"}, "input": {"name": "select"}},
)
local_port = tables.Column(verbose_name="Local Port", attrs={"td": {"data-col": "local_port"}})
remote_port = tables.Column(
accessor="remote_port_name",
verbose_name="Remote Port",
attrs={"td": {"data-col": "remote_port"}},
)
remote_device = tables.Column(verbose_name="Remote Device", attrs={"td": {"data-col": "remote_device"}})
cable_status = tables.Column(verbose_name="Cable Status", attrs={"td": {"data-col": "cable_status"}})
actions = tables.TemplateColumn(
template_code="""
{% if record.can_create_cable %}
<button type="submit"
class="btn btn-sm btn-primary"
onclick="document.getElementById('selected_port').value='{{ record.local_port_id }}'">
Sync Cable
</button>
{% endif %}
""",
verbose_name="",
orderable=False,
attrs={"td": {"data-col": "actions"}},
)
def __init__(self, *args, device=None, **kwargs):
"""Initialize table with optional device context."""
self.device = device
super().__init__(*args, **kwargs)
self.tab = "cables"
self.htmx_url = None
self.prefix = "cables_"
def render_remote_device(self, value, record):
"""Render remote device name as a link if URL is available."""
if url := record.get("remote_device_url"):
return format_html('<a href="{}">{}</a>', url, value)
return value
def render_local_port(self, value, record):
"""Render local port name as a link if URL is available."""
if url := record.get("local_port_url"):
return format_html('<a href="{}">{}</a>', url, value)
return value
def render_remote_port(self, value, record):
"""Render remote port name as a link if URL is available."""
if url := record.get("remote_port_url"):
return format_html('<a href="{}">{}</a>', url, value)
return value
def render_cable_status(self, value, record):
"""Render cable status as a link if cable URL is available."""
if url := record.get("cable_url"):
return format_html('<a href="{}">{}</a>', url, value)
return value
def configure(self, request):
"""Configure pagination for the table using the current request."""
paginate = {
"paginator_class": EnhancedPaginator,
"per_page": get_table_paginate_count(request, self.prefix),
}
tables.RequestConfig(request, paginate).configure(self)
class Meta:
"""Define column sequence, row attributes, and table styling."""
sequence = [
"selection",
"local_port",
"remote_port",
"remote_device",
"cable_status",
"actions",
]
row_attrs = {
"data-interface": lambda record: record["local_port_id"],
"data-device": lambda record: record["device_id"],
"data-name": lambda record: record["local_port"],
}
attrs = {"class": "table table-hover object-list", "id": "librenms-cable-table"}
class VCCableTable(LibreNMSCableTable):
"""
Table for displaying LibreNMS cable data for Virtual Chassis devices.
"""
device_selection = tables.Column(
verbose_name="Virtual Chassis Member",
accessor="local_port_id",
attrs={"td": {"class": "device-selection-col", "data-col": "device_selection"}},
)
def __init__(self, *args, device=None, **kwargs):
"""Initialize the VC cable table with device context."""
super().__init__(*args, device=device, **kwargs)
def render_device_selection(self, value, record):
"""Render a dropdown to select the virtual chassis member for a port."""
members = self.device.virtual_chassis.members.all()
chassis_member = get_virtual_chassis_member(self.device, record["local_port"])
selected_member_id = chassis_member.id if chassis_member else self.device.id
port_id = record["local_port_id"]
options = [
f'<option value="{member.id}"{" selected" if member.id == selected_member_id else ""}>{escape(member.name)}</option>'
for member in members
]
return format_html(
'<select name="device_selection_{0}" id="device_selection_{0}" class="form-select" data-interface="{0}" data-row-id="{0}">{1}</select>',
port_id,
mark_safe("".join(options)),
)
class Meta(LibreNMSCableTable.Meta):
"""Define column sequence and attributes for the VC cable table."""
sequence = [
"selection",
"device_selection",
"local_port",
"remote_port",
"remote_device",
"cable_status",
"actions",
]
row_attrs = {
"data-interface": lambda record: record["local_port_id"],
"data-device": lambda record: record["device_id"],
"data-name": lambda record: record["local_port"],
"id": lambda record: record["local_port_id"],
}
attrs = {
"class": "table table-hover object-list",
"id": "librenms-cable-table-vc",
}

View File

@@ -0,0 +1,722 @@
import json
import django_tables2 as tables
from dcim.models import Device
from dcim.tables import DeviceTable
from django.urls import reverse
from django.utils.html import escape
from django.utils.safestring import mark_safe
from django_tables2 import Column
from virtualization.models import VirtualMachine
from netbox_librenms_plugin.utils import get_librenms_sync_device
class DeviceStatusTable(DeviceTable):
"""
Table for displaying device LibreNMS status.
"""
librenms_status = Column(
verbose_name="LibreNMS Status",
empty_values=(),
accessor="librenms_status",
orderable=False,
)
def render_librenms_status(self, value, record):
"""Render LibreNMS sync status with link to sync page."""
sync_url = reverse(
"plugins:netbox_librenms_plugin:device_librenms_sync",
kwargs={"pk": record.pk},
)
# Check if device is VC member and redirect to sync device if different
if hasattr(record, "virtual_chassis") and record.virtual_chassis:
sync_device = get_librenms_sync_device(record)
if sync_device and record.pk != sync_device.pk:
sync_device_url = reverse(
"plugins:netbox_librenms_plugin:device_librenms_sync",
kwargs={"pk": sync_device.pk},
)
return mark_safe(
f'<a href="{sync_device_url}"><span class="text-info">'
f'<i class="mdi mdi-server-network"></i> See {sync_device.name}</span></a>'
)
if value:
status = '<span class="text-success"><i class="mdi mdi-check-circle"></i> Found</span>'
elif value is False:
status = '<span class="text-danger"><i class="mdi mdi-close-circle"></i> Not Found</span>'
else:
status = '<span class="text-secondary"><i class="mdi mdi-help-circle"></i> Unknown</span>'
return mark_safe(f'<a href="{sync_url}">{status}</a>')
class Meta(DeviceTable.Meta):
"""Meta options for DeviceStatusTable."""
model = Device
fields = (
"pk",
"name",
"status",
"tenant",
"site",
"location",
"rack",
"role",
"manufacturer",
"device_type",
"device_role",
"librenms_status",
)
default_columns = (
"name",
"status",
"site",
"location",
"rack",
"device_type",
"role",
"librenms_status",
)
class DeviceImportTable(tables.Table):
"""
Table for displaying LibreNMS devices available for import.
Shows validation status and provides import actions.
Uses plain django_tables2.Table since we're working with dictionaries, not model instances.
"""
name = "DeviceImportTable" # Required by NetBox table utilities
def __init__(self, *args, **kwargs):
"""Initialize table with cached querysets and apply sorting."""
super().__init__(*args, **kwargs)
# Cache querysets to avoid N queries per render
from dcim.models import DeviceRole
from virtualization.models import Cluster
self._cached_clusters = list(Cluster.objects.all().order_by("name"))
self._cached_roles = list(DeviceRole.objects.all().order_by("name"))
# Apply sorting if order_by is specified
# Since we're working with dictionaries, not QuerySets, we handle sorting manually
if self.order_by:
self._sort_data()
def _sort_data(self):
"""Sort table data based on order_by parameter."""
if not self.data:
return
# Get the ordering field and direction
order_by = self.order_by[0] if isinstance(self.order_by, (list, tuple)) else self.order_by
reverse = order_by.startswith("-")
field = order_by.lstrip("-")
# Map column names to data keys
field_map = {
"hostname": "hostname",
"sysname": "sysName",
"location": "location",
"hardware": "hardware",
}
data_key = field_map.get(field)
if not data_key:
return # Unknown field, skip sorting
# Sort the data list in place
# Handle None values by treating them as empty strings for sorting
def sort_key(item):
"""Return lowercase sort value for a data field."""
value = item.get(data_key, "")
return (value or "").lower() if isinstance(value, str) else str(value or "")
try:
self.data.data.sort(key=sort_key, reverse=reverse)
except (AttributeError, TypeError):
# If data is a plain list, sort it directly
if isinstance(self.data, list):
self.data.sort(key=sort_key, reverse=reverse)
# Selection checkbox
selection = Column(
verbose_name="",
empty_values=(),
orderable=False,
accessor="device_id",
)
# LibreNMS device fields
hostname = Column(verbose_name="Hostname", accessor="hostname", orderable=True)
sysname = Column(verbose_name="System Name", accessor="sysName", orderable=True)
location = Column(verbose_name="Location", accessor="location", orderable=True)
hardware = Column(verbose_name="Hardware", accessor="hardware", orderable=True)
# Cluster selection - if selected, import as VM; otherwise import as Device
netbox_cluster = Column(
verbose_name="NetBox Cluster",
empty_values=(),
orderable=False,
accessor="device_id",
)
# NetBox role selection (for devices only)
netbox_role = Column(
verbose_name="NetBox Role",
empty_values=(),
orderable=False,
accessor="device_id",
)
# NetBox rack selection (for devices only, optional)
netbox_rack = Column(
verbose_name="NetBox Rack",
empty_values=(),
orderable=False,
accessor="device_id",
)
# Virtual Chassis detection column
virtual_chassis = Column(
verbose_name="Virtual Chassis",
empty_values=(),
orderable=False,
accessor="device_id",
)
# Actions column
actions = Column(
verbose_name="Actions",
empty_values=(),
orderable=False,
accessor="device_id",
)
def render_selection(self, value, record):
"""
Render selection checkbox.
Disabled if device can't be imported.
"""
validation = record.get("_validation", {})
can_import = validation.get("can_import", False)
device_id = record.get("device_id")
hostname = record.get("hostname", "")
sysname = record.get("sysName", "")
if can_import:
return mark_safe(
f'<input type="checkbox" name="select" value="{device_id}" '
f'class="form-check-input device-select" data-device-id="{device_id}" '
f'data-hostname="{hostname}" data-sysname="{sysname}">'
)
else:
return mark_safe(
'<input type="checkbox" disabled class="form-check-input" title="Cannot import this device">'
)
def render_hostname(self, value, record):
"""Render hostname with link to LibreNMS if available."""
return mark_safe(f"<strong>{value}</strong>")
def render_netbox_cluster(self, value, record):
"""
Render cluster selection dropdown.
Default is "-- Device (not VM) --" (empty value).
If a cluster is selected, the device will be imported as a VM.
If no cluster is selected, the device will be imported as a Device.
"""
device_id = record.get("device_id")
validation = record.get("_validation", {})
existing = validation.get("existing_device")
# Check if existing object is a VM
if existing and isinstance(existing, VirtualMachine):
# VM already exists - show its cluster (cluster is required for VMs)
cluster = existing.cluster
return mark_safe(f'<span class="badge bg-info text-white">{cluster.name}</span>')
# If Device already exists (not VM), show it's not a VM
if existing:
return mark_safe('<span class="text-muted small">Device (not VM)</span>')
# Use cached clusters to avoid N queries
clusters = self._cached_clusters
# Check if a cluster has been selected (from validation)
selected_cluster_id = None
if validation.get("cluster", {}).get("found") and validation.get("cluster", {}).get("cluster"):
selected_cluster_id = validation["cluster"]["cluster"].pk
# Build dropdown with HTMX attributes to update the row
options = ['<option value="">-- Device (not VM) --</option>']
for cluster in clusters:
selected = " selected" if cluster.pk == selected_cluster_id else ""
options.append(f'<option value="{cluster.pk}"{selected}>{cluster.name}</option>')
# Add HTMX attributes to update the entire row when cluster is selected
from django.urls import reverse
update_url = reverse(
"plugins:netbox_librenms_plugin:device_cluster_update",
kwargs={"device_id": device_id},
)
# Include VC detection flag in URL if present in validation (from initial load)
vc_detection_flag = ""
if validation.get("_vc_detection_enabled"):
vc_detection_flag = "?enable_vc_detection=true"
select_html = (
f'<select class="form-select form-select-sm cluster-select" '
f'name="cluster_{device_id}" '
f'data-device-id="{device_id}" '
f'hx-post="{update_url}{vc_detection_flag}" '
f'hx-trigger="change" '
f'hx-swap="none" '
f'hx-include="[name=role_{device_id}], [name=rack_{device_id}]" '
f'style="width: 180px;">'
f"{''.join(options)}"
f"</select>"
)
return mark_safe(select_html)
def render_netbox_role(self, value, record):
"""
Render role selection dropdown.
For Devices: Role is required
For VMs: Role is optional
"""
device_id = record.get("device_id")
validation = record.get("_validation", {})
is_vm = validation.get("import_as_vm", False)
existing = validation.get("existing_device")
# If device/VM already exists, show its role with NetBox's defined color
if existing and hasattr(existing, "role") and existing.role:
role = existing.role
# Use the role's color if available, otherwise fallback to info
color = role.color if hasattr(role, "color") and role.color else "6c757d"
return mark_safe(
f'<span class="badge" style="background-color: #{color}; color: white;">{role.name}</span>'
)
# Use cached roles to avoid N queries
roles = self._cached_roles
# Check if a role has been selected (from validation)
selected_role_id = None
if validation.get("device_role", {}).get("found") and validation.get("device_role", {}).get("role"):
selected_role_id = validation["device_role"]["role"].pk
# Build dropdown with different text based on import type
if is_vm:
placeholder = "-- Select Role (Optional) --"
else:
placeholder = "-- Select Role --"
options = [f'<option value="">{placeholder}</option>']
for role in roles:
selected = " selected" if role.pk == selected_role_id else ""
options.append(f'<option value="{role.pk}"{selected}>{role.name}</option>')
# Add HTMX attributes to update the entire row when role is selected
from django.urls import reverse
update_url = reverse(
"plugins:netbox_librenms_plugin:device_role_update",
kwargs={"device_id": device_id},
)
# Include VC detection flag in URL if present in validation (from initial load)
vc_detection_flag = ""
if validation.get("_vc_detection_enabled"):
vc_detection_flag = "?enable_vc_detection=true"
select_html = (
f'<select class="form-select form-select-sm device-role-select" '
f'name="role_{device_id}" '
f'data-device-id="{device_id}" '
f'hx-post="{update_url}{vc_detection_flag}" '
f'hx-trigger="change" '
f'hx-swap="none" '
f'hx-include="[name=cluster_{device_id}], [name=rack_{device_id}]" '
f'style="width: 150px;">'
f"{''.join(options)}"
f"</select>"
)
return mark_safe(select_html)
def render_netbox_rack(self, value, record):
"""
Render rack selection dropdown (optional).
Shows racks for the matched site in "Location - Rack" format.
Only shown for devices (not VMs) and when site is matched.
"""
device_id = record.get("device_id")
validation = record.get("_validation", {})
is_vm = validation.get("import_as_vm", False)
existing = validation.get("existing_device")
# Don't show rack dropdown for VMs
if is_vm:
return mark_safe('<span class="text-muted small">N/A (VM)</span>')
# If device already exists, show its rack
if existing and hasattr(existing, "rack") and existing.rack:
rack = existing.rack
location_name = rack.location.name if rack.location else "No Location"
return mark_safe(f'<span class="badge bg-info text-white">{location_name} - {rack.name}</span>')
# If device exists but no rack assigned
if existing:
return mark_safe('<span class="text-muted small">No rack</span>')
# Check if site is matched - rack selection only available when site is known
site_found = validation.get("site", {}).get("found", False)
if not site_found:
return mark_safe('<span class="text-muted small">--</span>')
# Get available racks from validation (cached)
available_racks = validation.get("rack", {}).get("available_racks", [])
# Check if a rack has been selected
selected_rack_id = None
if validation.get("rack", {}).get("rack"):
selected_rack_id = validation["rack"]["rack"].pk
# Build dropdown with HTMX attributes
options = ['<option value="">--</option>']
for rack in available_racks:
location_name = rack.location.name if rack.location else "No Location"
display_text = f"{location_name} - {rack.name}"
selected = " selected" if rack.pk == selected_rack_id else ""
options.append(f'<option value="{rack.pk}"{selected}>{escape(display_text)}</option>')
# Add HTMX attributes to update the entire row when rack is selected
from django.urls import reverse
update_url = reverse(
"plugins:netbox_librenms_plugin:device_rack_update",
kwargs={"device_id": device_id},
)
# Include VC detection flag in URL if present in validation (from initial load)
vc_detection_flag = ""
if validation.get("_vc_detection_enabled"):
vc_detection_flag = "?enable_vc_detection=true"
select_html = (
f'<select class="form-select form-select-sm rack-select" '
f'name="rack_{device_id}" '
f'data-device-id="{device_id}" '
f'hx-post="{update_url}{vc_detection_flag}" '
f'hx-trigger="change" '
f'hx-swap="none" '
f'hx-include="[name=cluster_{device_id}], [name=role_{device_id}]" '
f'style="width: 200px;">'
f"{''.join(options)}"
f"</select>"
)
return mark_safe(select_html)
def render_actions(self, value, record):
"""
Render action buttons for import using HTMX.
Shows Import button if can import, otherwise shows Preview/Configure.
Permission checks are handled by backend require_write_permission() which shows toast.
"""
validation = record.get("_validation", {})
device_id = record.get("device_id")
is_ready = validation.get("is_ready", False)
can_import = validation.get("can_import", False)
existing = validation.get("existing_device")
vc_attributes = self._build_vc_attributes(validation, record)
buttons = []
if existing:
# Link to existing device/VM in NetBox + details button for conflict resolution
if isinstance(existing, VirtualMachine):
url_name = "virtualization:virtualmachine"
title = "View VM in NetBox"
else:
url_name = "dcim:device"
title = "View Device in NetBox"
device_url = reverse(url_name, kwargs={"pk": existing.pk})
buttons.append(
f'<a href="{device_url}" class="btn btn-sm btn-secondary" '
f'title="{title}" aria-label="{title}"><i class="mdi mdi-open-in-new"></i></a>'
)
# Add details/conflict button for conflict resolution actions
details_url = self._build_validation_details_url(device_id, validation)
match_type = validation.get("existing_match_type", "")
serial_action = validation.get("serial_action")
has_mismatch = validation.get("device_type_mismatch", False)
has_actions = match_type == "hostname" or (match_type == "serial" and serial_action is not None)
has_name_sync = validation.get("name_sync_available", False)
has_sync_needed = match_type == "librenms_id" and serial_action in ("update_serial", "conflict")
if has_mismatch:
btn_class = "btn-outline-danger"
btn_icon = "mdi-alert-circle"
btn_label = " Conflict"
btn_title = "View conflict details"
elif has_actions:
btn_class = "btn-outline-warning"
btn_icon = "mdi-alert"
btn_label = " Conflict"
btn_title = "View conflict details"
elif has_name_sync or has_sync_needed:
btn_class = "btn-outline-warning"
btn_icon = "mdi-information-outline"
btn_label = " Details"
btn_title = "View details"
elif match_type == "librenms_id" and validation.get("librenms_id_needs_migration"):
btn_class = "btn-outline-warning"
btn_icon = "mdi-database-alert"
btn_label = " Legacy ID"
btn_title = "View legacy ID migration details"
else:
btn_class = "btn-outline-success"
btn_icon = "mdi-check-circle"
btn_label = ""
btn_title = "View details"
aria_attr = f'aria-label="{btn_title}" '
buttons.append(
f'<button type="button" '
f'class="btn btn-sm {btn_class}" '
f"{aria_attr}"
f'hx-get="{details_url}" '
f'hx-include="[name=cluster_{device_id}], [name=role_{device_id}], [name=rack_{device_id}], #use-sysname-toggle, #strip-domain-toggle" '
f'hx-target="#htmx-modal-content" '
f'hx-swap="innerHTML" '
f'title="{btn_title}">'
f'<i class="mdi {btn_icon}"></i>{btn_label}</button>'
)
elif is_ready:
# Ready to import - show Import and Details buttons
details_url = self._build_validation_details_url(device_id, validation)
buttons.append(
f'<button type="button" '
f'class="btn btn-sm btn-success device-import-btn device-ready" '
f'data-device-id="{device_id}" '
f'data-import-mode="single"{vc_attributes} '
f'title="Import this device">'
f'<i class="mdi mdi-download"></i> Import</button>'
)
buttons.append(
f'<button type="button" '
f'class="btn btn-sm btn-outline-primary" '
f'aria-label="View details" '
f'hx-get="{details_url}" '
f'hx-include="[name=cluster_{device_id}], [name=role_{device_id}], [name=rack_{device_id}], #use-sysname-toggle, #strip-domain-toggle" '
f'hx-target="#htmx-modal-content" '
f'hx-swap="innerHTML" '
f'title="View details">'
f'<i class="mdi mdi-information-outline"></i></button>'
)
elif can_import:
# Has warnings - show Review button with Details
details_url = self._build_validation_details_url(device_id, validation)
buttons.append(
f'<button type="button" '
f'class="btn btn-sm btn-warning" '
f'hx-get="{details_url}" '
f'hx-include="[name=cluster_{device_id}], [name=role_{device_id}], [name=rack_{device_id}], #use-sysname-toggle, #strip-domain-toggle" '
f'hx-target="#htmx-modal-content" '
f'hx-swap="innerHTML" '
f'title="Review and import">'
f'<i class="mdi mdi-alert"></i> Review</button>'
)
else:
# Cannot import (usually missing role) - show Import button (disabled until role selected) and Details
details_url = self._build_validation_details_url(device_id, validation)
buttons.append(
f'<button type="button" '
f'class="btn btn-sm btn-success device-import-btn" '
f'data-device-id="{device_id}" '
f"disabled{vc_attributes} "
f'title="Select a role to enable import">'
f'<i class="mdi mdi-download"></i> Import</button>'
)
buttons.append(
f'<button type="button" '
f'class="btn btn-sm btn-outline-danger" '
f'hx-get="{details_url}" '
f'hx-include="[name=cluster_{device_id}], [name=role_{device_id}], [name=rack_{device_id}], #use-sysname-toggle, #strip-domain-toggle" '
f'hx-target="#htmx-modal-content" '
f'hx-swap="innerHTML" '
f'title="View validation details">'
f'<i class="mdi mdi-alert-circle"></i> Details</button>'
)
return mark_safe('<div class="btn-group btn-group-sm">' + " ".join(buttons) + "</div>")
def render_virtual_chassis(self, value, record):
"""Render Virtual Chassis status and details button."""
validation = record.get("_validation", {})
vc_data = validation.get("virtual_chassis", {})
device_id = record.get("device_id")
# Show dash for non-VC or single member stacks
if not vc_data.get("is_stack") or vc_data.get("member_count", 0) <= 1:
return mark_safe('<span class="text-muted">—</span>')
vc_url = reverse(
"plugins:netbox_librenms_plugin:device_vc_details",
kwargs={"device_id": device_id},
)
# Show error button if detection failed
if vc_data.get("detection_error"):
return mark_safe(
f'<button type="button" '
f'class="btn btn-sm btn-outline-warning" '
f'hx-get="{vc_url}" '
f'hx-target="#htmx-modal-content" '
f'hx-swap="innerHTML" '
f'title="View virtual chassis error details">'
f'<i class="mdi mdi-alert"></i> Error</button>'
)
# Show member count button for valid multi-member stacks
member_count = vc_data.get("member_count", 0)
return mark_safe(
f'<button type="button" '
f'class="btn btn-sm btn-outline-info" '
f'hx-get="{vc_url}" '
f'hx-target="#htmx-modal-content" '
f'hx-swap="innerHTML" '
f'title="View virtual chassis details">'
f'<i class="mdi mdi-server-network"></i> {member_count} members</button>'
)
@staticmethod
def _build_validation_details_url(device_id: int, validation: dict) -> str:
"""
Build validation details URL with appropriate query parameters.
Constructs the URL for the device validation details modal, adding
cluster_id, role_id, and VC detection flag as query parameters.
Args:
device_id: LibreNMS device ID
validation: Validation dict from validate_device_for_import()
Returns:
str: Complete URL with query parameters
"""
details_url = reverse(
"plugins:netbox_librenms_plugin:device_validation_details",
kwargs={"device_id": device_id},
)
# Build query params based on import type
params = []
# Add cluster_id if this is a VM import
if validation.get("cluster", {}).get("found") and validation.get("cluster", {}).get("cluster"):
cluster_id = validation["cluster"]["cluster"].id
params.append(f"cluster_id={cluster_id}")
# Add role_id if device role is found
elif validation.get("device_role", {}).get("found") and validation.get("device_role", {}).get("role"):
role_id = validation["device_role"]["role"].id
params.append(f"role_id={role_id}")
# Add VC detection flag if it was enabled during initial load
if validation.get("_vc_detection_enabled"):
params.append("enable_vc_detection=true")
if params:
details_url += "?" + "&".join(params)
return details_url
@staticmethod
def _build_vc_attributes(validation: dict, record: dict) -> str:
vc_data = validation.get("virtual_chassis") or {}
if not vc_data.get("is_stack"):
return ' data-vc-is-stack="false"'
members_payload = []
for member in vc_data.get("members", []):
members_payload.append(
{
"position": member.get("position"),
"serial": member.get("serial"),
"suggested_name": member.get("suggested_name"),
}
)
payload = {
"member_count": vc_data.get("member_count", len(members_payload)),
"members": members_payload,
"detection_error": vc_data.get("detection_error"),
}
payload_json = escape(json.dumps(payload))
master_name = record.get("hostname") or record.get("sysName") or ""
master_value = escape(master_name)
return (
' data-vc-is-stack="true"'
f' data-vc-member-count="{payload["member_count"]}"'
f' data-vc-info="{payload_json}"'
f' data-vc-master="{master_value}"'
)
class Meta:
"""Meta options for DeviceImportTable."""
# No model - we're working with LibreNMS API dictionaries, not Django model instances
# This prevents NetBoxTable from auto-adding custom fields from Device model
# Add row attributes to give each row a unique ID for HTMX targeting
row_attrs = {
"id": lambda record: f"device-row-{record.get('device_id')}",
}
fields = (
"selection",
"hostname",
"sysname",
"location",
"hardware",
"netbox_cluster",
"netbox_role",
"netbox_rack",
"virtual_chassis",
"actions",
)
sequence = (
"selection",
"hostname",
"sysname",
"location",
"hardware",
"netbox_cluster",
"netbox_role",
"netbox_rack",
"virtual_chassis",
"actions",
)
default_columns = fields
orderable = True
attrs = {
"class": "table table-hover",
"id": "device-import-table",
}

View File

@@ -0,0 +1,616 @@
import json as json_module
import django_tables2 as tables
from django.utils.html import escape, format_html
from django.utils.safestring import mark_safe
from netbox.tables.columns import BooleanColumn, ToggleColumn
from utilities.paginator import EnhancedPaginator
from utilities.templatetags.helpers import humanize_speed
from netbox_librenms_plugin.models import InterfaceTypeMapping
from netbox_librenms_plugin.utils import (
check_vlan_group_matches,
convert_speed_to_kbps,
format_mac_address,
get_interface_name_field,
get_librenms_device_id,
get_missing_vlan_warning,
get_table_paginate_count,
get_tagged_vlan_css_class,
get_untagged_vlan_css_class,
get_virtual_chassis_member,
)
class LibreNMSInterfaceTable(tables.Table):
"""
Table for displaying LibreNMS interface data.
"""
class Meta:
"""Meta options for LibreNMSInterfaceTable."""
sequence = [
"selection",
"name",
"type",
"speed",
"vlans",
"mac_address",
"mtu",
"enabled",
"description",
"librenms_id",
]
attrs = {
"class": "table table-hover object-list",
"id": "librenms-interface-table",
}
def __init__(self, *args, device=None, interface_name_field=None, vlan_groups=None, server_key=None, **kwargs):
"""Initialize table with device context and interface name field."""
self.device = device
self.interface_name_field = interface_name_field or get_interface_name_field()
self.vlan_groups = vlan_groups or []
self.server_key = server_key
# Update column accessors after initialization
for column in ["selection", "name"]:
self.base_columns[column].accessor = self.interface_name_field
# Set row attributes using interface_name_field
self._meta.row_attrs = {
"data-interface": lambda record: record.get(self.interface_name_field),
"data-name": lambda record: record.get(self.interface_name_field),
"data-enabled": lambda record: (
str(record.get("ifAdminStatus")).lower() if record.get("ifAdminStatus") is not None else ""
),
}
super().__init__(*args, **kwargs)
self.tab = "interfaces"
self.htmx_url = None
self.prefix = "interfaces_"
selection = ToggleColumn(
orderable=False,
visible=True,
attrs={"td": {"data-col": "selection"}, "input": {"name": "select"}},
)
name = tables.Column(verbose_name="Name", attrs={"td": {"data-col": "name"}})
type = tables.Column(
accessor="ifType",
verbose_name="Interface Type",
attrs={"td": {"data-col": "type"}},
)
speed = tables.Column(accessor="ifSpeed", verbose_name="Speed", attrs={"td": {"data-col": "speed"}})
mac_address = tables.Column(
accessor="ifPhysAddress",
verbose_name="MAC Address",
attrs={"td": {"data-col": "mac_address"}},
)
mtu = tables.Column(accessor="ifMtu", verbose_name="MTU", attrs={"td": {"data-col": "mtu"}})
enabled = BooleanColumn(verbose_name="Enabled", attrs={"td": {"data-col": "enabled"}})
description = tables.Column(
accessor="ifAlias",
verbose_name="Description",
attrs={"td": {"data-col": "description"}},
)
librenms_id = tables.Column(
accessor="port_id",
verbose_name="LibreNMS ID",
attrs={"td": {"data-col": "librenms_id"}},
)
vlans = tables.Column(
verbose_name="VLANs",
empty_values=(),
orderable=False,
attrs={"td": {"data-col": "vlans"}},
)
def render_vlans(self, value, record):
"""
Render VLANs column showing untagged and tagged VLANs.
Format: "100(U), 200(T), 300(T)" or "100(U)" for access ports.
Color logic:
- Red + warning icon: VLAN not in any NetBox group (cannot sync)
- Red: Not present in NetBox (no VLAN assigned on interface)
- Orange: Mismatched (different untagged VLAN assigned)
- Green: Matching (VLAN matches NetBox assignment)
Compact display: shows up to 3 VLANs inline, then summarizes.
An edit button opens the VLAN detail modal.
Hidden inputs store per-VLAN group assignments for form submission.
"""
untagged = record.get("untagged_vlan")
tagged = record.get("tagged_vlans", [])
missing_vlans = record.get("missing_vlans", [])
# Get NetBox interface for comparison
exists_in_netbox = record.get("exists_in_netbox", False)
netbox_interface = record.get("netbox_interface")
# Get NetBox VLAN assignments (VID + group for group-aware comparison)
netbox_untagged_vid = None
netbox_untagged_group_id = None
netbox_tagged_vids = set()
netbox_tagged_group_ids = {}
if netbox_interface:
if netbox_interface.untagged_vlan:
netbox_untagged_vid = netbox_interface.untagged_vlan.vid
netbox_untagged_group_id = netbox_interface.untagged_vlan.group_id
for v in netbox_interface.tagged_vlans.all():
netbox_tagged_vids.add(v.vid)
netbox_tagged_group_ids[v.vid] = v.group_id
all_vlans = []
if untagged:
all_vlans.append(("U", untagged))
for vid in sorted(tagged):
all_vlans.append(("T", vid))
if not all_vlans:
return mark_safe("")
interface_name = record.get(self.interface_name_field, "")
safe_name = interface_name.replace("/", "_").replace(":", "_")
# Build compact colored summary (show up to 3 VLANs, summarize rest)
vlan_group_map = record.get("vlan_group_map", {})
MAX_INLINE = 3
inline_parts = []
for vlan_type, vid in all_vlans[:MAX_INLINE]:
selected_gid = self._parse_group_id(vlan_group_map.get(vid, {}).get("group_id", ""))
group_matches = check_vlan_group_matches(
vlan_type,
vid,
selected_gid,
netbox_untagged_group_id,
netbox_tagged_group_ids,
netbox_untagged_vid,
netbox_tagged_vids,
)
if vlan_type == "U":
css = get_untagged_vlan_css_class(
vid, netbox_untagged_vid, exists_in_netbox, missing_vlans, group_matches
)
else:
css = get_tagged_vlan_css_class(vid, netbox_tagged_vids, exists_in_netbox, missing_vlans, group_matches)
warning = get_missing_vlan_warning(vid, missing_vlans)
inline_parts.append(f'<span class="{css}">{vid}({vlan_type}){warning}</span>')
summary = ", ".join(inline_parts)
if len(all_vlans) > MAX_INLINE:
extra = len(all_vlans) - MAX_INLINE
summary += f' <span class="text-muted">+{extra} more</span>'
# Build tooltip showing auto-selected VLAN group per VLAN
tooltip_lines = []
for vlan_type, vid in all_vlans:
if vid in missing_vlans:
tooltip_lines.append(f"VLAN {vid}({vlan_type}) → ⚠ Not in NetBox")
else:
group_info = vlan_group_map.get(vid, {})
group_name = group_info.get("group_name", "Global")
tooltip_lines.append(f"VLAN {vid}({vlan_type}) → {escape(group_name)}")
tooltip_text = "&#10;".join(tooltip_lines)
# Build hidden inputs for per-VLAN group selections (submitted with form)
hidden_inputs = []
for vlan_type, vid in all_vlans:
group_info = vlan_group_map.get(vid, {})
group_id = group_info.get("group_id", "")
hidden_inputs.append(
format_html(
'<input type="hidden" name="vlan_group_{}_{}" '
'value="{}" class="vlan-group-hidden" '
'data-interface="{}" data-vid="{}">',
safe_name,
vid,
group_id,
interface_name,
vid,
)
)
# Build JSON data for modal (use proper json serialization for safety)
vlan_json_items = []
for vlan_type, vid in all_vlans:
group_info = vlan_group_map.get(vid, {})
is_missing = vid in missing_vlans
selected_gid = self._parse_group_id(group_info.get("group_id", ""))
group_matches = check_vlan_group_matches(
vlan_type,
vid,
selected_gid,
netbox_untagged_group_id,
netbox_tagged_group_ids,
netbox_untagged_vid,
netbox_tagged_vids,
)
if vlan_type == "U":
css = get_untagged_vlan_css_class(
vid, netbox_untagged_vid, exists_in_netbox, missing_vlans, group_matches
)
else:
css = get_tagged_vlan_css_class(vid, netbox_tagged_vids, exists_in_netbox, missing_vlans, group_matches)
display_group_name = "Not in NetBox" if is_missing else group_info.get("group_name", "Global")
vlan_json_items.append(
{
"vid": vid,
"type": vlan_type,
"group_id": group_info.get("group_id", ""),
"group_name": display_group_name,
"css": css,
"missing": is_missing,
}
)
vlan_json = json_module.dumps(vlan_json_items)
device_id = self.device.pk if self.device else ""
# Build vlan_groups JSON for modal dropdowns
group_options = [{"id": "", "name": "-- No Group (Global) --", "scope": ""}]
for group in self.vlan_groups:
scope_info = str(group.scope) if hasattr(group, "scope") and group.scope else ""
group_options.append({"id": str(group.pk), "name": group.name, "scope": scope_info})
groups_json = json_module.dumps(group_options)
# Escape JSON for safe embedding in HTML attributes
escaped_vlan_json = escape(vlan_json)
escaped_groups_json = escape(groups_json)
edit_btn = format_html(
'<button type="button" class="btn btn-sm btn-link p-0 ms-1 vlan-edit-btn" '
'data-interface="{}" '
'data-safe-name="{}" '
'data-device-id="{}" '
"data-vlans='{}' "
"data-vlan-groups='{}' "
'title="Edit VLAN group assignments">'
'<i class="mdi mdi-pencil"></i></button>',
interface_name,
safe_name,
device_id,
escaped_vlan_json,
escaped_groups_json,
)
hidden_inputs_html = mark_safe("".join(str(h) for h in hidden_inputs))
return format_html(
'<span title="{}">{}</span>{}{}',
mark_safe(tooltip_text),
mark_safe(summary),
edit_btn,
hidden_inputs_html,
)
@staticmethod
def _parse_group_id(group_id_str):
"""Normalize a group ID string to int or None for comparison."""
return int(group_id_str) if group_id_str else None
def render_speed(self, value, record):
"""Render interface speed with appropriate styling based on comparison with NetBox"""
kbps_value = convert_speed_to_kbps(value)
return self._render_field(humanize_speed(kbps_value), record, "ifSpeed", "speed")
def render_name(self, value, record):
"""Render interface name with appropriate styling based on comparison with NetBox"""
return self._render_field(value, record, self.interface_name_field, "name")
def _get_interface_status_display(self, enabled, record):
"""
Determine interface status display and CSS class based on enabled state and NetBox comparison.
Args:
enabled (bool): Interface enabled state.
record (dict): Interface data record.
Returns:
tuple: (display_value, css_class)
"""
display_value = "Enabled" if enabled else "Disabled"
if not record.get("exists_in_netbox"):
return display_value, "text-danger"
netbox_interface = record.get("netbox_interface")
if netbox_interface:
netbox_enabled = netbox_interface.enabled
if enabled == netbox_enabled:
return display_value, "text-success"
return display_value, "text-warning"
return display_value, "text-danger"
def _parse_enabled_status(self, value):
"""Convert interface status value to boolean enabled state"""
if isinstance(value, str):
return value.lower() == "up"
return bool(value)
def render_enabled(self, value, record):
"""Render interface enabled status with appropriate styling based on comparison with NetBox"""
enabled = self._parse_enabled_status(value)
display_value, css_class = self._get_interface_status_display(enabled, record)
return format_html('<span class="{}">{}</span>', css_class, display_value)
def render_description(self, value, record):
"""Render interface description with appropriate styling based on comparison with NetBox"""
return self._render_field(value, record, "ifAlias", "description")
def render_mac_address(self, value, record):
"""Render MAC address with appropriate styling based on comparison with NetBox"""
formatted_mac = format_mac_address(value)
return self._render_field(formatted_mac, record, "ifPhysAddress", "mac_address")
def render_mtu(self, value, record):
"""Render MTU with appropriate styling based on comparison with NetBox"""
return self._render_field(value, record, "ifMtu", "mtu")
def render_librenms_id(self, value, record):
"""Render the 'librenms_id' field with appropriate styling based on comparison with NetBox."""
if not record.get("exists_in_netbox"):
return mark_safe(f'<span class="text-danger">{value}</span>')
netbox_interface = record.get("netbox_interface")
if not netbox_interface:
return mark_safe(f'<span class="text-danger">{value}</span>')
netbox_librenms_id = get_librenms_device_id(netbox_interface, self.server_key, auto_save=False)
if netbox_librenms_id is None:
return mark_safe(
f'<span class="text-danger" title="No librenms_id custom field value found">{value}</span>'
)
# Compare the IDs
if str(value) != str(netbox_librenms_id):
# IDs do not match
return mark_safe(
f'<span class="text-warning" title="Existing LibreNMS ID: {netbox_librenms_id}">{value}</span>'
)
else:
# IDs match
return mark_safe(f'<span class="text-success">{value}</span>')
def _compare_mac_addresses(self, librenms_mac, netbox_interface):
"""
Compare LibreNMS MAC address against all MAC addresses on NetBox interface.
Args:
librenms_mac (str): MAC address from LibreNMS.
netbox_interface (Interface): NetBox interface record.
Returns:
True if MAC exists on interface.
"""
if not netbox_interface:
return False
interface_macs = [mac.mac_address for mac in netbox_interface.mac_addresses.all()]
return librenms_mac in interface_macs
def _render_field(self, value, record, librenms_key, netbox_key):
"""Render a field value with appropriate styling based on the comparison with NetBox."""
if not record.get("exists_in_netbox"):
return mark_safe(f'<span class="text-danger">{value}</span>')
netbox_interface = record.get("netbox_interface")
if not netbox_interface:
return mark_safe(f'<span class="text-danger">{value}</span>')
if librenms_key == "ifPhysAddress":
mac_matches = self._compare_mac_addresses(value, netbox_interface)
css_class = "text-success" if mac_matches else "text-warning"
return mark_safe(f'<span class="{css_class}">{value}</span>')
netbox_value = getattr(netbox_interface, netbox_key, None)
librenms_value = record.get(librenms_key)
if librenms_key == "ifSpeed":
librenms_value = convert_speed_to_kbps(librenms_value)
if librenms_value != netbox_value:
return mark_safe(f'<span class="text-warning">{value}</span>')
return mark_safe(f'<span class="text-success">{value}</span>')
def render_type(self, value, record):
"""Render interface type with appropriate styling based on comparison with NetBox"""
speed = convert_speed_to_kbps(record.get("ifSpeed", 0))
mapping = self.get_interface_mapping(value, speed)
tooltip_value, icon = self.render_mapping_tooltip(value, speed, mapping)
combined_display = format_html("{} {}", tooltip_value, icon)
if not record.get("exists_in_netbox"):
return format_html('<span class="text-danger">{}</span>', combined_display)
netbox_interface = record.get("netbox_interface")
if netbox_interface:
netbox_type = getattr(netbox_interface, "type", None)
if mapping and mapping.netbox_type == netbox_type:
return format_html('<span class="text-success">{}</span>', combined_display)
elif mapping:
return format_html('<span class="text-warning">{}</span>', combined_display)
return format_html('<span class="text-danger">{}</span>', combined_display)
def get_interface_mapping(self, librenms_type, speed):
"""Get interface type mapping based on type and speed"""
# First try exact match with type and speed
mapping = InterfaceTypeMapping.objects.filter(librenms_type=librenms_type, librenms_speed=speed).first()
# If no match found, fall back to type-only match
if not mapping:
mapping = InterfaceTypeMapping.objects.filter(
librenms_type=librenms_type, librenms_speed__isnull=True
).first()
return mapping
def render_mapping_tooltip(self, value, speed, mapping):
"""Render tooltip for interface type mapping"""
if mapping:
display = mapping.netbox_type
icon = format_html(
'<i class="mdi mdi-link-variant" title="Mapped from LibreNMS type: {} (Speed: {})"></i>',
value,
speed,
)
else:
display = value
icon = mark_safe('<i class="mdi mdi-link-variant-off" title="No mapping to NetBox type"></i>')
return display, icon
def format_interface_data(self, port_data, device):
"""Format single interface data using table rendering logic"""
# Add NetBox interface data
interface_name = port_data.get(self.interface_name_field)
port_data["netbox_interface"] = device.interfaces.filter(name=interface_name).first()
port_data["exists_in_netbox"] = bool(port_data["netbox_interface"])
# Clear description if it matches interface name
if port_data["ifAlias"] == port_data["ifName"] or port_data["ifAlias"] == port_data["ifDescr"]:
port_data["ifAlias"] = ""
formatted_data = {
"name": self.render_name(interface_name, port_data),
"type": self.render_type(port_data["ifType"], port_data),
"speed": self.render_speed(port_data["ifSpeed"], port_data),
"mac_address": self.render_mac_address(port_data["ifPhysAddress"], port_data),
"mtu": self.render_mtu(port_data["ifMtu"], port_data),
"enabled": self.render_enabled(port_data["ifAdminStatus"], port_data),
"description": self.render_description(port_data["ifAlias"], port_data),
}
return formatted_data
def configure(self, request):
"""Configure the table with pagination and other options"""
paginate = {
"paginator_class": EnhancedPaginator,
"per_page": get_table_paginate_count(request, self.prefix),
}
tables.RequestConfig(request, paginate).configure(self)
class VCInterfaceTable(LibreNMSInterfaceTable):
"""
Table for displaying Virtual Chassis interface data.
"""
device_selection = tables.Column(
verbose_name="Virtual Chassis member",
accessor="device",
orderable=False,
empty_values=[],
attrs={"td": {"data-col": "device_selection"}},
)
def __init__(self, *args, device=None, interface_name_field=None, vlan_groups=None, **kwargs):
"""Initialize VC interface table with device and name field."""
super().__init__(
*args, device=device, interface_name_field=interface_name_field, vlan_groups=vlan_groups, **kwargs
)
# Ensure device_selection column is visible
if hasattr(self.device, "virtual_chassis") and self.device.virtual_chassis:
self.columns.show("device_selection")
# Update selection column accessor to match interface_name_field
self.base_columns["selection"].accessor = self.interface_name_field
def render_device_selection(self, value, record):
"""
Renders a device selection dropdown for virtual chassis members.
Determines the selected member based on interface type and name.
Returns an HTML select element with appropriate member options.
"""
members = self.device.virtual_chassis.members.all()
if_type = record.get("ifType", "").lower()
interface_name = record.get(self.interface_name_field)
if "ethernet" in if_type:
chassis_member = get_virtual_chassis_member(self.device, interface_name)
selected_member_id = chassis_member.id if chassis_member else self.device.id
else:
selected_member_id = self.device.id
# Create unique base ID for TomSelect components
base_id = f"device_selection_{interface_name}_{hash(interface_name)}"
options = [
f'<option value="{member.id}"{" selected" if member.id == selected_member_id else ""}>{member.name}</option>'
for member in members
]
return format_html(
'<select name="device_selection_{0}" id="{1}" class="form-select vc-member-select" data-interface="{0}" data-row-id="{0}">{2}</select>',
interface_name,
base_id,
mark_safe("".join(options)),
)
def format_interface_data(self, port_data, device):
"""Format interface data including VC device selection column."""
formatted_data = super().format_interface_data(port_data, device)
formatted_data["device_selection"] = self.render_device_selection(None, port_data)
return formatted_data
class Meta:
"""Meta options for VCInterfaceTable."""
sequence = [
"selection",
"device_selection",
"name",
"type",
"speed",
"vlans",
"mac_address",
"mtu",
"enabled",
"description",
]
attrs = {
"class": "table table-hover object-list",
"id": "librenms-interface-table",
}
class LibreNMSVMInterfaceTable(LibreNMSInterfaceTable):
"""
Table for displaying LibreNMS VM interface data.
"""
class Meta(LibreNMSInterfaceTable.Meta):
"""Meta options for LibreNMSVMInterfaceTable."""
sequence = [
"selection",
"name",
"vlans",
"mac_address",
"mtu",
"enabled",
"description",
]
attrs = {
"class": "table table-hover object-list",
"id": "librenms-interface-table-vm",
}
# Remove the type and speed column for VMs
type = None
speed = None

View File

@@ -0,0 +1,122 @@
import django_tables2 as tables
from django.utils.html import format_html, mark_safe
from netbox.tables.columns import ToggleColumn
from utilities.paginator import EnhancedPaginator
from netbox_librenms_plugin.utils import get_table_paginate_count
class IPAddressTable(tables.Table):
"""
Table for displaying LibreNMS IP address data.
"""
def __init__(self, *args, **kwargs):
"""Initialize IP address table."""
super().__init__(*args, **kwargs)
class Meta:
"""Meta options for IPAddressTable."""
sequence = [
"selection",
"address",
"prefix_length",
"device",
"interface_name",
"vrf",
]
attrs = {
"class": "table table-hover object-list",
"id": "librenms-ipaddress-table",
}
row_attrs = {
"data-interface": lambda record: record["ip_address"],
"data-name": lambda record: record["ip_address"],
}
selection = ToggleColumn(
orderable=False,
visible=True,
attrs={"td": {"data-col": "selection"}, "input": {"name": "select"}},
accessor="ip_address",
)
address = tables.Column(
accessor="ip_address",
verbose_name="IP Address",
linkify=lambda record: record.get("ip_url"),
attrs={"td": {"data-col": "address"}},
)
prefix_length = tables.Column(
accessor="prefix_length",
verbose_name="Prefix Length",
attrs={"td": {"data-col": "prefix"}},
)
device = tables.Column(
linkify=lambda record: record.get("device_url"),
attrs={"td": {"data-col": "device"}},
)
interface_name = tables.Column(
accessor="interface_name",
verbose_name="Interface",
linkify=lambda record: record.get("interface_url"),
attrs={"td": {"data-col": "interface"}},
)
vrf = tables.TemplateColumn(
template_code="""
<select id="vrf_select_{{ record.ip_address|slugify }}" class="form-select vrf-select" data-ip="{{ record.ip_address }}" data-prefix="{{ record.prefix_length }}" data-row-id="{{ record.ip_address }}" name="vrf_{{ record.ip_address }}">
<option value="">Global</option>
{% for vrf in record.vrfs %}
<option value="{{ vrf.pk }}" {% if record.vrf_id == vrf.pk %}selected{% endif %}>
{{ vrf.name }}
</option>
{% endfor %}
</select>
""",
attrs={"td": {"data-col": "vrf"}},
verbose_name="VRF",
)
status = tables.Column(
verbose_name="Status",
attrs={"td": {"data-col": "status"}},
)
def render_status(self, value, record):
"""Render the status column with appropriate buttons or text styling"""
if value == "update":
return format_html(
'<button type="submit" class="btn btn-sm btn-warning" onclick="document.getElementById(\'selected_ip\').value=\'{}\'">'
'<i class="mdi mdi-pencil" aria-hidden="true"></i> Update</button>',
record["ip_address"],
)
elif value == "matched":
return mark_safe('<span class="text-success"><i class="mdi mdi-check-circle"></i> Synced</span>')
elif record.get("interface_url"):
return format_html(
'<button type="submit" class="btn btn-sm btn-primary" onclick="document.getElementById(\'selected_ip\').value=\'{}\'">'
'<i class="mdi mdi-plus-thick" aria-hidden="true"></i> Create</button>',
record["ip_address"],
)
return mark_safe('<span class="text-muted">Missing NetBox Object</span>')
def render_device(self, value, record):
"""Render the device column with a link if available"""
if url := record.get("device_url"):
return format_html('<a href="{}">{}</a>', url, value)
return value
def render_interface_name(self, value, record):
"""Render the interface column with a link if available"""
if url := record.get("interface_url"):
return format_html('<a href="{}">{}</a>', url, value)
return value
def configure(self, request):
"""Configure the table"""
paginate = {
"paginator_class": EnhancedPaginator,
"per_page": get_table_paginate_count(request, self.prefix),
}
tables.RequestConfig(request, paginate).configure(self)

View File

@@ -0,0 +1,84 @@
import django_tables2 as tables
from django.middleware.csrf import get_token
from django.utils.html import format_html
from django.utils.safestring import mark_safe
from utilities.paginator import EnhancedPaginator, get_paginate_count
class SiteLocationSyncTable(tables.Table):
"""
Table for displaying Netbox Site and Librenms Location data.
"""
netbox_site = tables.Column(linkify=True)
latitude = tables.Column(accessor="netbox_site__latitude")
longitude = tables.Column(accessor="netbox_site__longitude")
librenms_location = tables.Column(accessor="librenms_location__location", verbose_name="LibreNMS Location")
librenms_latitude = tables.Column(accessor="librenms_location__lat", verbose_name="LibreNMS Latitude")
librenms_longitude = tables.Column(accessor="librenms_location__lng", verbose_name="LibreNMS Longitude")
actions = tables.Column(empty_values=())
def render_latitude(self, value, record):
"""Render latitude with sync-status styling."""
return self.render_coordinate(value, record.is_synced)
def render_longitude(self, value, record):
"""Render longitude with sync-status styling."""
return self.render_coordinate(value, record.is_synced)
def render_coordinate(self, value, is_synced):
"""Render coordinate with success or danger text color."""
css_class = "text-success" if is_synced else "text-danger"
return format_html('<span class="{}">{}</span>', css_class, value)
def render_actions(self, record):
"""Render action buttons with styles based on sync status or action."""
csrf_token = get_token(self.request)
if record.is_synced:
return mark_safe(
'<span class="text-success"><i class="mdi mdi-check-circle" aria-hidden="true"></i> Synced</span>'
)
if record.librenms_location:
return mark_safe(
f'<form method="post">'
f'<input type="hidden" name="csrfmiddlewaretoken" value="{csrf_token}">'
f'<input type="hidden" name="action" value="update">'
f'<input type="hidden" name="pk" value="{record.netbox_site.pk}">'
'<button type="submit" class="btn btn-sm btn-warning">'
'<i class="mdi mdi-pencil" aria-hidden="true"></i> Update in LibreNMS'
"</button>"
"</form>"
)
else:
return mark_safe(
f'<form method="post">'
f'<input type="hidden" name="csrfmiddlewaretoken" value="{csrf_token}">'
f'<input type="hidden" name="action" value="create">'
f'<input type="hidden" name="pk" value="{record.netbox_site.pk}">'
'<button type="submit" class="btn btn-sm btn-primary">'
'<i class="mdi mdi-plus-thick" aria-hidden="true"></i> Create in LibreNMS'
"</button>"
"</form>"
)
def configure(self, request):
"""Configure the table with pagination and custom attributes."""
paginate = {
"paginator_class": EnhancedPaginator,
"per_page": get_paginate_count(request),
}
tables.RequestConfig(request, paginate).configure(self)
class Meta:
"""Meta options for SiteLocationSyncTable."""
fields = (
"netbox_site",
"latitude",
"longitude",
"librenms_location",
"librenms_latitude",
"librenms_longitude",
"actions",
)
attrs = {"class": "table table-hover table-headings table-striped"}

View File

@@ -0,0 +1,38 @@
import django_tables2 as tables
from netbox.tables import NetBoxTable, columns
from netbox_librenms_plugin.models import InterfaceTypeMapping
class InterfaceTypeMappingTable(NetBoxTable):
"""
Table for displaying InterfaceTypeMapping data.
"""
librenms_type = tables.Column(verbose_name="LibreNMS Type")
librenms_speed = tables.Column(verbose_name="LibreNMS Speed (Kbps)")
netbox_type = tables.Column(verbose_name="NetBox Type")
description = tables.Column(verbose_name="Description", linkify=False)
actions = columns.ActionsColumn(actions=("edit", "delete"))
class Meta:
"""Meta options for InterfaceTypeMappingTable."""
model = InterfaceTypeMapping
fields = (
"id",
"librenms_type",
"librenms_speed",
"netbox_type",
"description",
"actions",
)
default_columns = (
"id",
"librenms_type",
"librenms_speed",
"netbox_type",
"description",
"actions",
)
attrs = {"class": "table table-hover table-headings table-striped"}

View File

@@ -0,0 +1,183 @@
import django_tables2 as tables
from django.utils.html import format_html, format_html_join
from django.utils.safestring import mark_safe
from netbox.tables.columns import ToggleColumn
from utilities.paginator import EnhancedPaginator
from netbox_librenms_plugin.constants import LIBRENMS_VLAN_STATE_ACTIVE
from netbox_librenms_plugin.utils import get_table_paginate_count, get_vlan_sync_css_class
class LibreNMSVLANTable(tables.Table):
"""
Table for displaying LibreNMS VLAN data for a device.
Shows VLANs configured on the device and their sync status with NetBox.
Includes per-row VLAN group selection dropdown.
"""
class Meta:
sequence = [
"selection",
"vlan_id",
"name",
"vlan_group_selection",
"type",
"state",
]
attrs = {
"class": "table table-hover object-list",
"id": "librenms-vlan-table",
}
row_attrs = {
"data-vlan-id": lambda record: record.get("vlan_id"),
}
def __init__(self, *args, vlan_groups=None, **kwargs):
super().__init__(*args, **kwargs)
self.prefix = "vlans_"
self.vlan_groups = vlan_groups or []
selection = ToggleColumn(
orderable=False,
visible=True,
attrs={"td": {"data-col": "selection"}, "input": {"name": "select"}},
accessor="vlan_id",
)
vlan_id = tables.Column(
accessor="vlan_id",
verbose_name="VLAN ID",
attrs={"td": {"data-col": "vlan_id"}},
)
name = tables.Column(
accessor="name",
verbose_name="Name",
attrs={"td": {"data-col": "name"}},
)
vlan_group_selection = tables.Column(
verbose_name="VLAN Group",
empty_values=(),
orderable=False,
attrs={"td": {"data-col": "vlan_group_selection"}},
)
type = tables.Column(
accessor="type",
verbose_name="Type",
attrs={"td": {"data-col": "type"}},
)
state = tables.Column(
accessor="state",
verbose_name="State",
attrs={"td": {"data-col": "state"}},
)
def render_vlan_id(self, value, record):
"""Render VLAN ID with color based on sync status."""
css_class = get_vlan_sync_css_class(
record.get("exists_in_netbox", False),
record.get("name_matches", True),
)
return format_html('<span class="{}">{}</span>', css_class, value)
def render_name(self, value, record):
"""Render VLAN name with color based on sync status."""
css_class = get_vlan_sync_css_class(
record.get("exists_in_netbox", False),
record.get("name_matches", True),
)
# Add tooltip on name mismatch
if record.get("exists_in_netbox") and not record.get("name_matches", True):
netbox_name = record.get("netbox_vlan_name", "")
tooltip = f"NetBox: {netbox_name} | LibreNMS: {value}"
return format_html(
'<span class="{}" title="{}">{}</span>',
css_class,
tooltip,
value or "",
)
return format_html('<span class="{}">{}</span>', css_class, value or "")
def render_vlan_group_selection(self, value, record):
"""
Render per-row VLAN group dropdown.
Auto-selects based on matching priority:
1. Existing NetBox VLAN's group (if exists_in_netbox)
2. Unique VID match (if VID exists in exactly one group)
3. No selection (with warning icon if ambiguous)
"""
vlan_id = record.get("vlan_id")
# Determine which group to auto-select
selected_group_id = None
# Priority 1: Existing NetBox VLAN group
if record.get("exists_in_netbox") and record.get("netbox_vlan_group_id"):
selected_group_id = record["netbox_vlan_group_id"]
elif record.get("auto_selected_group_id"):
# Priority 2: unique VID match
selected_group_id = record["auto_selected_group_id"]
# Build the select element using format_html_join to prevent XSS
options_html = format_html_join(
"",
'<option value="{}" data-scope="{}"{}>{}{}</option>',
[
(
"",
"",
"",
"-- No Group (Global) --",
"",
),
]
+ [
(
group.pk,
group.scope_id if group.scope_id else "",
" selected" if group.pk == selected_group_id else "",
group.name,
f" ({group.scope})" if group.scope else "",
)
for group in self.vlan_groups
],
)
select_html = format_html(
'<select name="vlan_group_{}" class="form-select form-select-sm vlan-sync-group-select"'
' data-vlan-id="{}" data-vlan-name="{}" style="min-width: 180px;">{}</select>',
vlan_id,
vlan_id,
record.get("name", ""),
options_html,
)
# Add warning icon if ambiguous (VID exists in multiple groups at same priority level)
if record.get("is_ambiguous") and not record.get("exists_in_netbox"):
warning_html = mark_safe(
'<i class="mdi mdi-alert text-warning ms-1" '
'title="VID exists in multiple groups at the same scope level. Please select the target group."></i>'
)
return format_html("{}{}", select_html, warning_html)
return select_html
def render_state(self, value, record):
"""Render VLAN state (active/inactive)."""
if value == LIBRENMS_VLAN_STATE_ACTIVE or value == "active":
return mark_safe('<span class="text-success">Active</span>')
return mark_safe('<span class="text-muted">Inactive</span>')
def configure(self, request):
"""Configure the table with pagination."""
paginate = {
"paginator_class": EnhancedPaginator,
"per_page": get_table_paginate_count(request, self.prefix),
}
tables.RequestConfig(request, paginate).configure(self)

View File

@@ -0,0 +1,28 @@
{% load helpers %}
{% load static %}
<!-- Action Buttons -->
<div class="d-flex justify-content-between align-items-center mb-3">
<h2>Cable Sync</h2>
<div class="btn-list">
<form method="post">
{% csrf_token %}
{% if has_librenms_id %}
{% with model_name=object|meta:"model_name" %}
{% if model_name == "device" %}
<button hx-post="{% url 'plugins:netbox_librenms_plugin:device_cable_sync' pk=object.pk %}"
hx-target="#cable-sync-content"
class="btn btn-outline-primary">
Refresh Cables
</button>
{% endif %}
{% endwith %}
{% endif %}
</form>
</div>
</div>
<!-- Container for the cable sync content -->
<div id="cable-sync-content">
{% include 'netbox_librenms_plugin/_cable_sync_content.html' %}
</div>

View File

@@ -0,0 +1,118 @@
{% load helpers %}
{% include 'inc/messages.html' %}
<!-- Cable Sync Table -->
{% if cable_sync.table %}
<form method="post" action="{% url 'plugins:netbox_librenms_plugin:sync_device_cables' cable_sync.object.pk %}">
{% csrf_token %}
{% if cable_sync.server_key %}<input type="hidden" name="server_key" value="{{ cable_sync.server_key }}">{% endif %}
<input type="hidden" id="selected_port" name="select" value="">
<div class="noprint d-flex justify-content-between align-items-center mt-3 mb-3">
<div>
<button type="submit" class="btn btn-primary">
<span class="spinner spinner-border d-none" id="sync-spinner"></span>
<span>Sync Selected Cables</span>
</button>
<a href="#" class="m-2" data-bs-toggle="modal" data-bs-target="#cableSyncHelpModal">
<i class="mdi mdi-help-circle"></i> info
</a>
</div>
{% if cable_sync.cache_expiry %}
<div id="cable-cache-countdown" class="me-3">
Cache expires in: <span id="cable-countdown-timer" data-expiry="{{ cable_sync.cache_expiry|date:'c' }}"></span>
</div>
{% endif %}
</div>
<div class="row mb-3">
<div class="col col-md-12">
<div class="d-flex justify-content-end align-items-center mb-3">
<button class="btn btn-sm btn-secondary" type="button" data-bs-toggle="collapse" data-bs-target="#cableFilterSection" aria-expanded="false" aria-controls="cableFilterSection">
<i class="mdi mdi-filter"></i> Toggle Filters
</button>
</div>
<div class="collapse mb-3" id="cableFilterSection">
<div class="mb-2">
<small class="text-muted">
<i class="mdi mdi-information"></i> Filters apply to currently displayed cables.
</small>
</div>
<div class="filter-container d-flex gap-2">
{% if cable_sync.table.attrs.id == "librenms-cable-table-vc" %}
<input type="text" id="filter-vc-member" placeholder="Filter by VC Member" class="form-control">
{% endif %}
<input type="text" id="filter-local-port" placeholder="Filter by Local Port" class="form-control">
<input type="text" id="filter-remote-port" placeholder="Filter by Remote Port" class="form-control">
<input type="text" id="filter-remote-device" placeholder="Filter by Remote Device" class="form-control">
</div>
</div>
<style>
/* Your existing CSS rules */
.ts-wrapper.multi .ts-control {
display: flex !important;
flex-wrap: wrap;
align-items: center;
}
/* Updated rules using min-width */
td[data-col="device_selection"] {
width: 300px;
min-width: 200px;
}
td[data-col="device_selection"] .ts-wrapper {
width: 100%;
max-width: 100%;
}
</style>
<div class="card">
{% include 'netbox_librenms_plugin/inc/paginator.html' with table=cable_sync.table %}
{% include 'inc/table.html' with table=cable_sync.table %}
{% include 'netbox_librenms_plugin/inc/paginator.html' with table=cable_sync.table %}
</div>
</div>
</div>
</form>
{% else %}
<div class="card">
<div class="card-body text-center text-muted py-4">
<i class="mdi mdi-sync-off mdi-48px"></i>
<p class="mt-2 mb-0">No cable data loaded. Click <strong>Refresh Cables</strong> to fetch data from LibreNMS.</p>
</div>
</div>
{% endif %}
<!-- Interface Type Help Modal -->
<div class="modal fade" id="cableSyncHelpModal" tabindex="-1" aria-labelledby="cableSyncHelpModalLabel"
aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="cableSyncHelpModalLabel">NetBox Interface Sync Info</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<h5>Device Association</h5>
<p>The plugin uses two methods to associate LibreNMS devices with NetBox devices:</p>
<ol>
<li><strong>LibreNMS ID (Recommended)</strong>
<ul>
<li>Uses the custom field 'librenms_id' on NetBox devices</li>
<li>Automatically populated when viewing the LibreNMS Sync page if device is found.</li>
<li>Can be manually entered in device custom fields</li>
<li>Provides the most reliable device matching</li>
</ul>
</li>
<li><strong>Device Name Fallback</strong>
<ul>
<li>Used when librenms_id is not available</li>
<li>Matches are case-sensitive</li>
<li>Less reliable due to potential naming differences</li>
</ul>
</li>
</ol>
<p>For best results, ensure the librenms_id custom field is populated on your devices.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,37 @@
{% load helpers %}
{% load static %}
<!-- Action Buttons -->
<div class="d-flex justify-content-between align-items-center mb-3">
<h2>Interface Sync</h2>
<div class="btn-list">
<form method="post">
{% csrf_token %}
{% if has_librenms_id %}
{% with model_name=object|meta:"model_name" %}
{% if model_name == "device" %}
<button hx-post="{% url 'plugins:netbox_librenms_plugin:device_interface_sync' pk=object.pk %}"
hx-target="#interface-sync-content"
hx-include="[name='interface_name_field']"
class="btn btn-outline-primary">
Refresh Interfaces
</button>
{% elif model_name == "virtualmachine" %}
<button hx-post="{% url 'plugins:netbox_librenms_plugin:vm_interface_sync' pk=object.pk %}"
hx-target="#interface-sync-content"
hx-include="[name='interface_name_field']"
class="btn btn-outline-primary">
Refresh Interfaces
</button>
{% endif %}
{% endwith %}
{% endif %}
</form>
</div>
</div>
<!-- End Action Buttons -->
<!-- Container for the interface sync content -->
<div id="interface-sync-content">
{% include 'netbox_librenms_plugin/_interface_sync_content.html' %}
</div>

View File

@@ -0,0 +1,358 @@
{% load helpers %}
{% include 'inc/messages.html' %}
<!-- Interface Sync Table -->
{% if interface_sync.table %}
{% with model_name=interface_sync.object|meta:"model_name" %}
<form method="post"
action="{% url 'plugins:netbox_librenms_plugin:sync_selected_interfaces' object_type=model_name object_id=interface_sync.object.pk %}?interface_name_field={{ interface_name_field }}">
{% endwith %}
{% csrf_token %}
{% if interface_sync.server_key %}<input type="hidden" name="server_key" value="{{ interface_sync.server_key }}">{% endif %}
{% block table_actions %}
<div class="noprint d-flex justify-content-between align-items-center mt-3 mb-3">
<div>
<button type="submit" class="btn btn-primary">
<span class="spinner spinner-border d-none" id="sync-spinner"></span>
<span>Sync Selected Interfaces</span>
</button>
<a href="#" class="m-2" data-bs-toggle="modal" data-bs-target="#interfaceTypeHelpModal">
<i class="mdi mdi-help-circle"></i> info
</a>
</div>
<div class="ms-auto d-flex align-items-center">
<div class="exclude-columns-section d-flex align-items-center gap-2 me-2">
<h6 class="mb-0">Exclude from Sync:</h6>
<div class="d-flex align-items-center m-0">
<span class="small me-1">Type</span>
<input class="form-check-input form-check-input-sm" type="checkbox" name="exclude_columns"
value="type" id="excludeType">
</div>
<div class="d-flex align-items-center m-1">
<span class="small me-1">Speed</span>
<input class="form-check-input form-check-input-sm" type="checkbox" name="exclude_columns"
value="speed" id="excludeSpeed">
</div>
<div class="d-flex align-items-center m-1">
<span class="small me-1">VLANs</span>
<input class="form-check-input form-check-input-sm" type="checkbox" name="exclude_columns"
value="vlans" id="excludeVlans">
</div>
<div class="d-flex align-items-center m-1">
<span class="small me-1">MAC</span>
<input class="form-check-input form-check-input-sm" type="checkbox" name="exclude_columns"
value="mac_address" id="excludeMACAddress">
</div>
<div class="d-flex align-items-center m-1">
<span class="small me-1">MTU</span>
<input class="form-check-input form-check-input-sm" type="checkbox" name="exclude_columns"
value="mtu" id="excludeMTU">
</div>
<div class="d-flex align-items-center m-1">
<span class="small me-1">Enabled</span>
<input class="form-check-input form-check-input-sm" type="checkbox" name="exclude_columns"
value="enabled" id="excludeEnabled">
</div>
<div class="d-flex align-items-center m-1">
<span class="small me-1">Description</span>
<input class="form-check-input form-check-input-sm" type="checkbox" name="exclude_columns"
value="description" id="excludeDescription">
</div>
</div>
</div>
</div>
{% endblock %} <!-- End block table_actions -->
<div class="row mb-3">
<div class="col col-md-12">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
{% if interface_sync.object.virtual_chassis %}
<button type="button" class="btn btn-secondary" id="bulk-vc-member-button" data-bs-toggle="modal"
data-bs-target="#bulkVCMemberModal" disabled>
Bulk Edit VC Member
</button>
{% endif %}
{% if interface_sync.netbox_only_interfaces %}
<a href="#" class="ms-2 text-warning text-decoration-none netbox-only-link" data-bs-toggle="modal"
data-bs-target="#netboxOnlyInterfacesModal"
title="Click to view and delete NetBox-only interfaces">
<i class="mdi mdi-alert-circle-outline me-1"></i>
{{interface_sync.netbox_only_interfaces|length}} NetBox only interfaces
</a>
{% endif %}
</div>
<div class="ms-auto d-flex align-items-center">
{% if interface_sync.cache_expiry %}
<div id="cache-countdown" class="me-3">
Cache expires in: <span id="countdown-timer"
data-expiry="{{ interface_sync.cache_expiry|date:'c' }}"></span>
</div>
{% endif %}
<div class="color-key me-3">
<span class="badge text-success text-white">Matching values</span>
<span class="badge text-warning text-white">Mismatched values</span>
<span class="badge text-danger text-white">Not present in NetBox</span>
</div>
</div>
<button class="btn btn-sm btn-secondary" type="button" data-bs-toggle="collapse"
data-bs-target="#interfaceFilterSection" aria-expanded="false"
aria-controls="interfaceFilterSection">
<i class="mdi mdi-filter"></i> Toggle Filters
</button>
</div>
<div class="collapse mb-3" id="interfaceFilterSection">
<div class="mb-2">
<small class="text-muted">
<i class="mdi mdi-information"></i> Filters apply to currently displayed interfaces. Adjust the
"per page" setting to apply the filters to more interfaces.
</small>
</div>
<div class="filter-container d-flex gap-2">
<input type="text" id="filter-name" placeholder="Filter by Name" class="form-control">
{% if interface_sync.table.attrs.id == 'librenms-interface-table' %}
<input type="text" id="filter-type" placeholder="Filter by Type" class="form-control">
<input type="text" id="filter-speed" placeholder="Filter by Speed" class="form-control">
{% endif %}
<input type="text" id="filter-mac" placeholder="Filter by MAC" class="form-control">
<input type="text" id="filter-mtu" placeholder="Filter by MTU" class="form-control">
<input type="text" id="filter-enabled" placeholder="Filter by Status" class="form-control">
<input type="text" id="filter-description" placeholder="Filter by Description" class="form-control">
</div>
</div>
<style>
/* Your existing CSS rules */
.ts-wrapper.multi .ts-control {
display: flex !important;
flex-wrap: wrap;
align-items: center;
}
/* Updated rules using min-width */
td[data-col="device_selection"] {
width: 300px;
min-width: 200px;
}
td[data-col="device_selection"] .ts-wrapper {
width: 100%;
max-width: 100%;
}
/* NetBox-only interfaces link styling */
.netbox-only-link {
cursor: pointer;
transition: opacity 0.2s ease;
}
.netbox-only-link:hover {
opacity: 0.8;
text-decoration: underline !important;
}
</style>
<div class="card">
{% include 'netbox_librenms_plugin/inc/paginator.html' with table=interface_sync.table %}
{% include 'inc/table.html' with table=interface_sync.table %}
{% include 'netbox_librenms_plugin/inc/paginator.html' with table=interface_sync.table %}
</div>
</div>
</div>
<!-- VLAN Detail Modal (inside form so hidden inputs are submitted) -->
<div class="modal fade" id="vlanDetailModal" tabindex="-1" aria-labelledby="vlanDetailModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="vlanDetailModalLabel">VLAN Assignments</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p class="text-muted mb-2">Interface: <strong id="vlanModalInterfaceName"></strong></p>
<table class="table table-sm table-hover mb-3" id="vlanDetailTable">
<thead>
<tr>
<th style="width: 80px;">VID</th>
<th style="width: 60px;">Type</th>
<th>VLAN Group</th>
</tr>
</thead>
<tbody id="vlanDetailTableBody">
<!-- Populated by JS -->
</tbody>
</table>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="applyVlanGroupToAll">
<label class="form-check-label" for="applyVlanGroupToAll">
Apply group assignments to all interfaces with matching VLANs
</label>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" id="saveVlanGroups">Save</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
</div>
</div>
</div>
</div>
</form>
{% else %}
<div class="card">
<div class="card-body text-center text-muted py-4">
<i class="mdi mdi-sync-off mdi-48px"></i>
<p class="mt-2 mb-0">No interface data loaded. Click <strong>Refresh Interfaces</strong> to fetch data from LibreNMS.</p>
</div>
</div>
{% endif %} <!-- End if interface_sync.table -->
<!-- Interface Type Help Modal -->
<div class="modal fade" id="interfaceTypeHelpModal" tabindex="-1" aria-labelledby="interfaceTypeHelpModalLabel"
aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="interfaceTypeHelpModalLabel">NetBox Interface Sync Info</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<h5>Interface Type mapping</h5>
<p>Interface type mappings control how LibreNMS interface types are translated to NetBox interface types
during synchronization. These mappings can be customized in the plugin settings menu. The icons in
the interface list indicate the mapping status for each interface type:</p>
<ul>
<li><i class="mdi mdi-link-variant"></i> - A mapping is configured for this interface type</li>
<li><i class="mdi mdi-link-variant-off"></i> - No mapping is currently set for this interface type
</li>
</ul>
<h5>Virtual Chassis Member Selection</h5>
<p>For devices that are part of a virtual chassis, the plugin will attempt to select the correct virutal
chassis member by matching the first number in the interface name to the device position in the
virutal chassis. The selected device can be changed in the table before sync the interface.</p>
<p>When changing the selected device for an interface row, the data will be checked again against the
newly selected device.</p>
<h5>VLAN Group Selection</h5>
<p>Each VLAN in the VLANs column is automatically assigned to a VLAN group using a priority
scope order: <strong>Rack → Location → Site → Site Group → Region → Global</strong>.
The most specific scope that contains the VLAN wins. If a VLAN exists in only one group,
that group is selected regardless of scope. Click the <i class="mdi mdi-pencil"></i> icon
on any row to change the group assignment for individual VLANs.</p>
<p>Check <strong>Apply group assignments to all interfaces with matching VLANs</strong> in the
modal to apply your selection to every interface that shares the same VLAN IDs. This choice is
saved for the duration of the cache, so subsequent table pages will also use it.</p>
<p>VLANs shown with a <i class="mdi mdi-alert text-danger"></i>
warning icon do not exist in the selected VLAN group in NetBox yet. Use the <strong>VLAN Sync</strong> tab to create them
before syncing interfaces.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<!-- Bulk VC Member Selection Modal -->
<div class="modal fade" id="bulkVCMemberModal" tabindex="-1" aria-labelledby="bulkVCMemberModalLabel"
aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="bulkVCMemberModalLabel">Set Virtual Chassis Member</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<label for="bulk-vc-member-select">Select Virtual Chassis Member:</label>
<select id="bulk-vc-member-select" class="form-select">
{% for member in interface_sync.virtual_chassis_members %}
<option value="{{ member.id }}">{{ member.name }}</option>
{% endfor %}
</select>
</div>
<div class="modal-footer">
<button class="btn btn-primary" id="apply-bulk-vc-member" data-bs-dismiss="modal">Apply</button>
<button class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
</div>
</div>
</div>
</div>
<!-- NetBox-only Interfaces Modal -->
<div class="modal fade" id="netboxOnlyInterfacesModal" tabindex="-1" aria-labelledby="netboxOnlyInterfacesModalLabel"
aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="netboxOnlyInterfacesModalLabel">
NetBox-only Interfaces - {{ interface_sync.object.name }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="alert alert-warning">
<i class="mdi mdi-alert me-2"></i>
<strong>Warning:</strong> The following interfaces exist in NetBox but are not found in the LibreNMS
data.
Deleting these interfaces will permanently remove them from NetBox. This action cannot be undone.
</div>
{% if interface_sync.netbox_only_interfaces %}
<form id="delete-netbox-interfaces-form">
{% csrf_token %}
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>
<input type="checkbox" id="select-all-netbox-interfaces"
class="form-check-input">
</th>
<th>Interface Name</th>
<th>Type</th>
<th>Status</th>
<th>Description</th>
</tr>
</thead>
<tbody>
{% for interface in interface_sync.netbox_only_interfaces %}
<tr>
<td>
<input type="checkbox" name="interface_ids" value="{{ interface.id }}"
class="form-check-input netbox-interface-checkbox">
</td>
<td>
<a href="{{ interface.url }}" target="_blank">{{ interface.name }}</a>
</td>
<td>{{ interface.type }}</td>
<td>
{% if interface.enabled %}
<span class="text-success">Enabled</span>
{% else %}
<span class="text-danger">Disabled</span>
{% endif %}
</td>
<td>{{ interface.description|default:"-" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</form>
{% else %}
<div class="alert alert-info">
<i class="mdi mdi-information me-2"></i>
No interfaces found that exist only in NetBox.
</div>
{% endif %}
</div>
<div class="modal-footer">
{% if interface_sync.netbox_only_interfaces %}
<button type="button" class="btn btn-danger" id="confirm-delete-interfaces">
<i class="mdi mdi-delete"></i> Delete Selected Interfaces
</button>
{% endif %}
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,37 @@
{% load helpers %}
{% load static %}
<!-- Action Buttons -->
<div class="d-flex justify-content-between align-items-center mb-3">
<h2>IP Address Sync</h2>
<div class="btn-list">
<form method="post">
{% csrf_token %}
{% if has_librenms_id %}
{% with model_name=object|meta:"model_name" %}
{% if model_name == "device" %}
<button hx-post="{% url 'plugins:netbox_librenms_plugin:device_ipaddress_sync' pk=object.pk %}"
hx-target="#ipaddress-sync-content"
hx-include="[name='interface_name_field']"
class="btn btn-outline-primary">
Refresh IP Addresses
</button>
{% elif model_name == "virtualmachine" %}
<button hx-post="{% url 'plugins:netbox_librenms_plugin:vm_ipaddress_sync' pk=object.pk %}"
hx-target="#ipaddress-sync-content"
hx-include="[name='interface_name_field']"
class="btn btn-outline-primary">
Refresh IP Addresses
</button>
{% endif %}
{% endwith %}
{% endif %}
</form>
</div>
</div>
<!-- End Action Buttons -->
<!-- Container for the interface sync content -->
<div id="ipaddress-sync-content">
{% include 'netbox_librenms_plugin/_ipaddress_sync_content.html' %}
</div>

View File

@@ -0,0 +1,84 @@
{% load helpers %}
{% include 'inc/messages.html' %}
<!-- IP Address Sync Table -->
{% if ip_sync.table %}
{% with model_name=ip_sync.object|meta:"model_name" %}
<form method="post" action="{% url 'plugins:netbox_librenms_plugin:sync_device_ip_addresses' object_type=model_name pk=ip_sync.object.pk %}">
{% endwith %}
{% csrf_token %}
{% if ip_sync.server_key %}<input type="hidden" name="server_key" value="{{ ip_sync.server_key }}">{% endif %}
<input type="hidden" id="selected_ip" name="select" value="">
<div class="noprint d-flex justify-content-between align-items-center mt-3 mb-3">
<div>
<button type="submit" class="btn btn-primary">
<span class="spinner spinner-border d-none" id="sync-spinner"></span>
<span>Sync Selected IP Addresses</span>
</button>
</div>
{% if ip_sync.cache_expiry %}
<div id="ip-cache-countdown" class="me-3">
Cache expires in: <span id="ip-countdown-timer" data-expiry="{{ ip_sync.cache_expiry|date:'c' }}"></span>
</div>
{% endif %}
</div>
<div class="row mb-3">
<div class="col col-md-12">
<div class="d-flex justify-content-end align-items-center mb-3">
<button class="btn btn-sm btn-secondary" type="button" data-bs-toggle="collapse" data-bs-target="#ipFilterSection" aria-expanded="false" aria-controls="ipFilterSection">
<i class="mdi mdi-filter"></i> Toggle Filters
</button>
</div>
<div class="collapse mb-3" id="ipFilterSection">
<div class="mb-2">
<small class="text-muted">
<i class="mdi mdi-information"></i> Filters apply to currently displayed IP addresses.
</small>
</div>
<div class="filter-container d-flex gap-2">
<input type="text" id="filter-address" placeholder="Filter by Address" class="form-control">
<input type="text" id="filter-prefix" placeholder="Filter by Prefix" class="form-control">
<input type="text" id="filter-device" placeholder="Filter by Device" class="form-control">
<input type="text" id="filter-interface" placeholder="Filter by Interface" class="form-control">
</div>
</div>
<style>
.ts-wrapper.multi .ts-control {
display: flex !important;
flex-wrap: wrap;
align-items: center;
}
/* VRF dropdown styles */
td[data-col="vrf"] {
width: 250px;
min-width: 180px;
}
td[data-col="vrf"] .ts-wrapper {
width: 100%;
max-width: 100%;
}
/* Ensure select elements in VRF column maintain consistent width */
td[data-col="vrf"] select.form-select {
width: 100%;
min-width: 160px;
}
</style>
<div class="card">
{% include 'netbox_librenms_plugin/inc/paginator.html' with table=ip_sync.table %}
{% include 'inc/table.html' with table=ip_sync.table %}
{% include 'netbox_librenms_plugin/inc/paginator.html' with table=ip_sync.table %}
</div>
</div>
</div>
</form>
{% else %}
<div class="card">
<div class="card-body text-center text-muted py-4">
<i class="mdi mdi-sync-off mdi-48px"></i>
<p class="mt-2 mb-0">No IP address data loaded. Click <strong>Refresh IP Addresses</strong> to fetch data from LibreNMS.</p>
</div>
</div>
{% endif %}

View File

@@ -0,0 +1,30 @@
{% load helpers %}
{% load static %}
<!-- VLAN Sync Header -->
<div class="d-flex justify-content-between align-items-center mb-3">
<h2>VLAN Sync</h2>
<div class="btn-list">
<form method="post">
{% csrf_token %}
{% if has_librenms_id %}
{% with model_name=object|meta:"model_name" %}
{% if model_name == "device" %}
<button hx-post="{% url 'plugins:netbox_librenms_plugin:device_vlan_sync' pk=object.pk %}"
hx-target="#vlan-sync-content"
hx-include="[name='interface_name_field']"
class="btn btn-outline-primary">
Refresh VLANs
</button>
{% endif %}
{% endwith %}
{% endif %}
</form>
</div>
</div>
<!-- End VLAN Sync Header -->
<!-- Container for the VLAN sync content -->
<div id="vlan-sync-content">
{% include 'netbox_librenms_plugin/_vlan_sync_content.html' %}
</div>

View File

@@ -0,0 +1,63 @@
{% load helpers %}
{% include 'inc/messages.html' %}
{% if vlan_sync.error_message %}
<!-- Error Message -->
<div class="alert alert-danger">
<i class="mdi mdi-alert-circle"></i> {{ vlan_sync.error_message }}
</div>
{% elif not vlan_sync.vlan_table or not vlan_sync.vlan_table.rows %}
<!-- No VLAN Data -->
<div class="card">
<div class="card-body text-center text-muted py-4">
<i class="mdi mdi-sync-off mdi-48px"></i>
<p class="mt-2 mb-0">No VLAN data loaded. Click <strong>Refresh VLANs</strong> to fetch data from LibreNMS.</p>
</div>
</div>
{% else %}
{% with model_name=vlan_sync.object|meta:"model_name" %}
<form method="post"
action="{% url 'plugins:netbox_librenms_plugin:sync_selected_vlans' object_type=model_name object_id=vlan_sync.object.pk %}">
{% endwith %}
{% csrf_token %}
{% if vlan_sync.server_key %}<input type="hidden" name="server_key" value="{{ vlan_sync.server_key }}">{% endif %}
<input type="hidden" name="action" value="create_vlans">
<div class="noprint d-flex justify-content-between align-items-center mt-3 mb-3">
<div>
<button type="submit" class="btn btn-primary">
<span class="spinner spinner-border d-none" id="sync-spinner"></span>
<span>Sync Selected VLANs</span>
</button>
<small class="text-muted ms-2">
<i class="mdi mdi-information-outline"></i> Select a VLAN Group for each row, or leave empty for global VLANs
</small>
</div>
<div class="ms-auto d-flex align-items-center">
{% if vlan_sync.cache_expiry %}
<div id="vlan-cache-countdown" class="me-3">
Cache expires in: <span id="vlan-countdown-timer"
data-expiry="{{ vlan_sync.cache_expiry|date:'c' }}"></span>
</div>
{% endif %}
<div class="color-key me-3">
<span class="badge text-success text-white">Matching values</span>
<span class="badge text-warning text-white">Mismatched values</span>
<span class="badge text-danger text-white">Not present in NetBox</span>
</div>
</div>
</div>
<div class="row mb-3">
<div class="col col-md-12">
<div class="card">
{% include 'netbox_librenms_plugin/inc/paginator.html' with table=vlan_sync.vlan_table %}
{% include 'inc/table.html' with table=vlan_sync.vlan_table %}
{% include 'netbox_librenms_plugin/inc/paginator.html' with table=vlan_sync.vlan_table %}
</div>
</div>
</div>
</form>
{% endif %}

View File

@@ -0,0 +1,212 @@
{# Confirmation modal content for bulk imports #}
<div class="modal-header">
<h5 class="modal-title">
<i class="mdi mdi-download"></i> Confirm Import
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p class="mb-3">Review the devices that will be created in NetBox.</p>
{% if errors %}
<div class="alert alert-warning" role="alert">
<i class="mdi mdi-alert"></i>
{{ errors|join:', ' }}
</div>
{% endif %}
{% for entry in devices %}
{% with vc=entry.validation.virtual_chassis %}
<div class="border rounded-3 p-2 mb-2">
{% if vc.is_stack and vc.members %}
<button class="d-flex flex-wrap align-items-center gap-2 w-100 text-start border-0 bg-transparent p-0 collapsed"
type="button"
data-bs-toggle="collapse"
data-bs-target="#bulk-vc-{{ entry.device_id }}"
aria-expanded="false">
{% else %}
<div class="d-flex flex-wrap align-items-center gap-2">
{% endif %}
<i class="mdi mdi-{{ entry.is_vm|yesno:'cloud,server' }} fs-4 text-primary"></i>
<div>
<div class="fw-semibold">{{ entry.device_name }}</div>
<div class="small text-muted">{{ entry.is_vm|yesno:'Virtual Machine,Device' }}</div>
</div>
{% if entry.role %}
<span class="badge text-white ms-1" style="background-color: #{{ entry.role.color|default:'6c757d' }};">{{ entry.role.name }}</span>
{% endif %}
{% if vc.is_stack %}
<span class="badge bg-info text-white text-uppercase fw-semibold small">
<i class="mdi mdi-switch"></i>
VC{% if vc.member_count %} · {{ vc.member_count }}{% endif %}
</span>
{% if vc.members %}
<i class="mdi mdi-chevron-down ms-auto"></i>
{% endif %}
{% endif %}
{% if vc.is_stack and vc.members %}
</button>
{% else %}
</div>
{% endif %}
<div class="text-muted small mt-1 d-flex flex-wrap gap-3">
{% if entry.cluster %}<span><strong>Cluster:</strong> {{ entry.cluster.name }}</span>{% endif %}
{% if entry.rack %}
<span>
<strong>Rack:</strong>
{% if entry.rack.location %}{{ entry.rack.location.name }} {% endif %}
{{ entry.rack.name }}
</span>
{% endif %}
</div>
{% if entry.validation.issues %}
<div class="alert alert-warning py-2 px-3 mt-3 mb-0 small">
<i class="mdi mdi-alert"></i>
{{ entry.validation.issues|join:'; ' }}
</div>
{% endif %}
{% if vc.is_stack and vc.members %}
<div class="collapse mt-2" id="bulk-vc-{{ entry.device_id }}">
<div class="border rounded">
<div class="px-3 pb-2">
<ul class="list-unstyled mb-0 small">
{% for member in vc.members %}
<li class="d-flex flex-wrap align-items-center py-2 {% if not forloop.last %}border-bottom{% endif %}">
<span class="text-muted me-2">
{% if member.position %}
Pos {{ member.position }}
{% else %}
Pos —
{% endif %}
</span>
<div class="flex-grow-1 d-flex align-items-center gap-2">
<code class="flex-grow-1 mb-0">{{ member.suggested_name }}
{% if member.is_master %}
<span class="badge bg-success text-white">Master</span>
{% endif %}
</code>
</div>
{% if member.serial %}
<span class="text-muted">SN {{ member.serial }}</span>
{% endif %}
</li>
{% endfor %}
</ul>
</div>
</div>
</div>
{% elif vc.is_stack and vc.detection_error %}
<div class="alert alert-warning py-2 px-3 mt-3 mb-0 small">
<i class="mdi mdi-alert"></i>
Unable to display virtual chassis members: {{ vc.detection_error }}
</div>
{% endif %}
</div>
{% endwith %}
{% endfor %}
</div>
<div class="modal-body pt-0">
<div class="p-3 bg-primary-subtle border border-primary rounded-1">
<div class="form-check">
<input type="checkbox" name="use_background_job" id="use-background-job-checkbox" class="form-check-input" checked>
<label class="form-check-label" for="use-background-job-checkbox">
Run as background job
</label>
<small class="form-text text-muted d-block mt-1">
Recommended: Jobs are logged and can be cancelled.
</small>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
<i class="mdi mdi-close"></i> Cancel
</button>
<form class="d-inline-flex align-items-center gap-2"
id="bulk-import-confirm-form"
hx-post="{% url 'plugins:netbox_librenms_plugin:bulk_import_devices' %}"
hx-target="body"
hx-swap="none">
{% csrf_token %}
<input type="hidden" name="server_key" value="{{ server_key }}">
<input type="hidden" name="vc_detection_enabled" value="{{ vc_detection_enabled|yesno:'on,off' }}">
{% for entry in devices %}
<input type="hidden" name="select" value="{{ entry.device_id }}">
{% if entry.role %}<input type="hidden" name="role_{{ entry.device_id }}" value="{{ entry.role.pk }}">{% endif %}
{% if entry.cluster %}<input type="hidden" name="cluster_{{ entry.device_id }}" value="{{ entry.cluster.pk }}">{% endif %}
{% if entry.rack %}<input type="hidden" name="rack_{{ entry.device_id }}" value="{{ entry.rack.pk }}">{% endif %}
{% endfor %}
<input type="hidden" name="use_sysname" value="{{ use_sysname|yesno:'true,false' }}">
<input type="hidden" name="strip_domain" value="{{ strip_domain|yesno:'true,false' }}">
<input type="hidden" name="enable_vc_detection" value="{{ vc_detection_enabled|yesno:'true,false' }}">
<input type="hidden" name="use_background_job" id="use-background-job-hidden" value="on">
<button type="submit" class="btn btn-success d-inline-flex align-items-center gap-1" data-bulk-confirm-submit>
<span data-state="idle" class="d-inline-flex align-items-center gap-1">
<i class="mdi mdi-download"></i>
Import {{ device_count }} device{{ device_count|pluralize }}
</span>
<span data-state="loading" class="d-none align-items-center gap-1">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
Importing...
</span>
</button>
</form>
</div>
<script>
(function() {
if (window.__bulkConfirmHandlersAttached) {
return;
}
window.__bulkConfirmHandlersAttached = true;
const formId = 'bulk-import-confirm-form';
const checkboxId = 'use-background-job-checkbox';
const hiddenId = 'use-background-job-hidden';
function toggleButtonState(isLoading) {
const form = document.getElementById(formId);
if (!form) {
return;
}
const button = form.querySelector('[data-bulk-confirm-submit]');
if (!button) {
return;
}
const idle = button.querySelector('[data-state="idle"]');
const loading = button.querySelector('[data-state="loading"]');
button.disabled = isLoading;
if (idle) {
idle.classList.toggle('d-none', isLoading);
}
if (loading) {
loading.classList.toggle('d-none', !isLoading);
}
}
// Delegated so it survives HTMX re-renders of the modal fragment.
document.addEventListener('change', function(event) {
if (event.target && event.target.id === checkboxId) {
const hiddenInput = document.getElementById(hiddenId);
if (hiddenInput) {
hiddenInput.value = event.target.checked ? 'on' : 'off';
}
}
});
document.addEventListener('htmx:beforeRequest', function(event) {
if (event.target && event.target.id === formId) {
toggleButtonState(true);
}
});
document.addEventListener('htmx:afterRequest', function(event) {
if (event.target && event.target.id === formId) {
toggleButtonState(false);
}
});
})();
</script>

View File

@@ -0,0 +1,22 @@
{% load render_table from django_tables2 %}
{% if table.rows %}
{% for row in table.rows %}
<tr {{ row.attrs.as_html }} hx-swap-oob="true">
{% for column, cell in row.items %}
<td {{ column.attrs.td.as_html }}>{{ cell }}</td>
{% endfor %}
</tr>
{% endfor %}
{% else %}
<tr id="device-row-{{ record.device_id }}">
<td colspan="13">
<div class="alert alert-danger">
ERROR: No table rows found for device {{ record.device_id }}
</div>
</td>
</tr>
{% endif %}
{# Consume and clear any pending Django messages to prevent reappearing toasts #}
<div id="django-messages" class="toast-container position-fixed bottom-0 end-0 p-3" hx-swap-oob="true">
{% for _ in messages %}{% endfor %}
</div>

View File

@@ -0,0 +1,625 @@
{# HTMX template for device validation details modal #}
{# Redesigned to match the sync page's clean table layout #}
<div class="modal-header">
<h5 class="modal-title">
{% if validation.existing_device %}
{% if validation.device_type_mismatch %}
<i class="mdi mdi-alert-circle text-danger"></i>
{% elif validation.existing_match_type == 'librenms_id' %}
<i class="mdi mdi-check-decagram text-info"></i>
{% else %}
<i class="mdi mdi-alert text-warning"></i>
{% endif %}
{% elif validation.is_ready %}
<i class="mdi mdi-check-circle text-success"></i>
{% elif validation.can_import %}
<i class="mdi mdi-alert text-warning"></i>
{% else %}
<i class="mdi mdi-close-circle text-danger"></i>
{% endif %}
Import Validation: {{ validation.resolved_name|default:libre_device.sysName|default:libre_device.hostname }}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
{# Compute existing device URL once for use throughout #}
{% if validation.existing_device %}
{% if existing_device_model_name == "virtualmachine" %}
{% url 'virtualization:virtualmachine' pk=validation.existing_device.pk as existing_device_url %}
{% else %}
{% url 'dcim:device' pk=validation.existing_device.pk as existing_device_url %}
{% endif %}
{% endif %}
{# Row: LibreNMS Status (left) + Device Info table (right) #}
<div class="row mb-3">
{# Left: LibreNMS Status card #}
<div class="col-md-4">
<div class="card h-100">
<h6 class="card-header">LibreNMS Status</h6>
<div class="card-body p-0">
<table class="table table-sm mb-0">
<tbody>
<tr>
<th style="padding-left: 0.75rem;">Status</th>
<td>
{% if libre_device.status == 1 or libre_device.status == "1" %}
<span class="badge bg-success text-white"><i class="mdi mdi-check"></i> Up</span>
{% elif libre_device.status == 0 or 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>
</div>
</div>
</div>
{# Right: Device Information table #}
<div class="col-md-8">
<div class="card h-100">
<h6 class="card-header d-flex justify-content-between align-items-center">
Device Information
<span class="d-flex gap-1">
{% if use_sysname %}
<span class="badge bg-success-lt"><i class="mdi mdi-check-circle-outline"></i> sysName</span>
{% else %}
<span class="badge bg-success-lt"><i class="mdi mdi-check-circle-outline"></i> hostname</span>
{% endif %}
{% if strip_domain %}
<span class="badge bg-success-lt"><i class="mdi mdi-check-circle-outline"></i> Domain stripped</span>
{% endif %}
</span>
</h6>
<div class="card-body p-0">
<table class="table table-sm table-hover mb-0">
<thead>
<tr>
<th style="width: 22%; padding-left: 0.75rem;">Field</th>
<th style="width: 39%;">NetBox Value</th>
<th style="width: 39%;">LibreNMS Value</th>
</tr>
</thead>
<tbody>
{# Name row #}
<tr>
<td style="padding-left: 0.75rem;">Name</td>
<td>
{% if validation.existing_device %}
<a href="{{ existing_device_url }}" target="_blank" rel="noopener noreferrer">
{{ validation.existing_device.name }}
</a>
{% if validation.name_sync_available %}
<form style="display:inline" class="ms-2"
hx-post="{% url 'plugins:netbox_librenms_plugin:device_conflict_action' device_id=libre_device.device_id %}"
hx-swap="none"
hx-include="#use-sysname-toggle, #strip-domain-toggle">
{% csrf_token %}
<input type="hidden" name="existing_device_id" value="{{ validation.existing_device.pk }}">
<input type="hidden" name="existing_device_type" value="{{ existing_device_model_name|default:'device' }}">
<input type="hidden" name="action" value="sync_name">
<button type="submit" class="btn btn-sm btn-outline-primary py-0 px-1" title="Sync name to {{ validation.suggested_name }}" aria-label="Sync name to {{ validation.suggested_name }}">
<i class="mdi mdi-sync"></i>
</button>
</form>
{% endif %}
{% else %}
<span class="text-muted">New device</span>
{% endif %}
</td>
<td>{{ validation.resolved_name|default:libre_device.sysName|default:libre_device.hostname }}</td>
</tr>
{# Show only for non-VM contexts: true when no existing device (new import, not VM) OR when existing device is not a VM. #}
{% if not validation.existing_device and not validation.import_as_vm or validation.existing_device and existing_device_model_name != "virtualmachine" %}
<tr>
<td style="padding-left: 0.75rem;">Site</td>
<td>
{% if validation.existing_device and validation.existing_device.site %}
{{ validation.existing_device.site }}
{% if validation.site.site and validation.existing_device.site.pk == validation.site.site.pk %}
<span class="text-success"><i class="mdi mdi-check-circle"></i></span>
{% endif %}
{% elif validation.site.site %}
{{ validation.site.site.name }}
<span class="text-success"><i class="mdi mdi-check-circle"></i></span>
{% else %}
<span class="text-danger"><i class="mdi mdi-close-circle"></i> No matching site</span>
{% endif %}
</td>
<td>{{ libre_device.location|default:"—" }}</td>
</tr>
{# Device Type row #}
<tr>
<td style="padding-left: 0.75rem;">Device Type</td>
<td>
{% if validation.device_type_mismatch %}
<span class="text-danger">
{{ validation.existing_device.device_type }}
<i class="mdi mdi-alert-circle"></i>
</span>
<form style="display:inline" class="ms-1"
hx-post="{% url 'plugins:netbox_librenms_plugin:device_conflict_action' device_id=libre_device.device_id %}"
hx-swap="none"
hx-include="#use-sysname-toggle, #strip-domain-toggle">
{% csrf_token %}
<input type="hidden" name="existing_device_id" value="{{ validation.existing_device.pk }}">
<input type="hidden" name="existing_device_type" value="{{ existing_device_model_name|default:'device' }}">
<input type="hidden" name="action" value="update_type">
<input type="hidden" name="force" value="on">
<button type="submit" class="btn btn-sm btn-outline-danger py-0 px-1" title="Update to LibreNMS type" aria-label="Update to LibreNMS type">
<i class="mdi mdi-sync"></i>
</button>
</form>
{% elif validation.existing_device and validation.existing_device.device_type %}
{{ validation.existing_device.device_type }}
{% if sync_info and not sync_info.device_type_synced and sync_info.librenms_device_type %}
<form style="display:inline" class="ms-1"
hx-post="{% url 'plugins:netbox_librenms_plugin:device_conflict_action' device_id=libre_device.device_id %}"
hx-swap="none">
{% csrf_token %}
<input type="hidden" name="existing_device_id" value="{{ validation.existing_device.pk }}">
<input type="hidden" name="existing_device_type" value="{{ existing_device_model_name|default:'device' }}">
<input type="hidden" name="action" value="sync_device_type">
<button type="submit" class="btn btn-sm btn-outline-primary py-0 px-1" title="Sync device type" aria-label="Sync device type">
<i class="mdi mdi-sync"></i>
</button>
</form>
{% elif validation.device_type.device_type %}
<span class="text-success"><i class="mdi mdi-check-circle"></i></span>
{% endif %}
{% elif validation.device_type.device_type %}
<span {% if validation.device_type.match_type == 'chassis' %}title="Matched via chassis inventory ({{ validation.device_type.chassis_model }})"{% elif validation.device_type.match_type == 'mapping' %}title="Matched via device type mapping"{% endif %}>
{{ validation.device_type.device_type }}
<span class="text-success"><i class="mdi mdi-check-circle"></i></span>
</span>
{% else %}
<span class="text-danger"><i class="mdi mdi-close-circle"></i> No matching type</span>
{% endif %}
</td>
<td>{{ libre_device.hardware|default:"—" }}</td>
</tr>
{% endif %}
{# Serial row (only for devices) #}
{# Show only for non-VM contexts: true when no existing device (new import, not VM) OR when existing device is not a VM. #}
{% if not validation.existing_device and not validation.import_as_vm or validation.existing_device and existing_device_model_name != "virtualmachine" %}
<tr>
<td style="padding-left: 0.75rem;">Serial</td>
<td>
{% if validation.existing_device %}
{% if validation.existing_device.serial %}
{{ validation.existing_device.serial }}
{% else %}
<span class="text-muted">Not set</span>
{% endif %}
{% if sync_info and not sync_info.serial_synced %}
{% if validation.serial_action == 'conflict' %}
<span class="text-danger ms-1" title="Serial conflict — another device already has this serial">
<i class="mdi mdi-alert-circle"></i>
</span>
{% else %}
<form style="display:inline" class="ms-1"
hx-post="{% url 'plugins:netbox_librenms_plugin:device_conflict_action' device_id=libre_device.device_id %}"
hx-swap="none">
{% csrf_token %}
<input type="hidden" name="existing_device_id" value="{{ validation.existing_device.pk }}">
<input type="hidden" name="existing_device_type" value="{{ existing_device_model_name|default:'device' }}">
<input type="hidden" name="action" value="sync_serial">
<button type="submit" class="btn btn-sm btn-outline-primary py-0 px-1" title="Sync serial from LibreNMS" aria-label="Sync serial from LibreNMS">
<i class="mdi mdi-sync"></i>
</button>
</form>
{% endif %}
{% elif sync_info and sync_info.serial_synced and sync_info.librenms_serial != '-' %}
<span class="text-success"><i class="mdi mdi-check-circle"></i></span>
{% endif %}
{% else %}
<span class="text-muted"></span>
{% endif %}
</td>
<td>
{{ libre_device.serial|default:"—" }}
{% if validation.serial_duplicate %}
<span class="badge bg-danger ms-1">Conflict</span>
{% endif %}
</td>
</tr>
{% endif %}
{# Role row #}
<tr>
<td style="padding-left: 0.75rem;">
{% if validation.import_as_vm %}Role{% else %}Device Role{% endif %}
</td>
<td>
{% if validation.existing_device and validation.existing_device.role %}
{{ validation.existing_device.role }}
<span class="text-success"><i class="mdi mdi-check-circle"></i></span>
{% elif validation.device_role.role %}
{{ validation.device_role.role.name }}
<span class="text-success"><i class="mdi mdi-check-circle"></i></span>
{% else %}
<span class="text-danger"><i class="mdi mdi-close-circle"></i> No role assigned</span>
{% endif %}
</td>
<td><span class="text-muted"></span></td>
</tr>
{# Platform row #}
<tr>
<td style="padding-left: 0.75rem;">Platform</td>
<td>
{% if validation.existing_device and validation.existing_device.platform %}
{{ validation.existing_device.platform }}
{% if sync_info and not sync_info.platform_synced %}
{% if sync_info.platform_info.platform_exists %}
<form style="display:inline" class="ms-1"
hx-post="{% url 'plugins:netbox_librenms_plugin:device_conflict_action' device_id=libre_device.device_id %}"
hx-swap="none">
{% csrf_token %}
<input type="hidden" name="existing_device_id" value="{{ validation.existing_device.pk }}">
<input type="hidden" name="existing_device_type" value="{{ existing_device_model_name|default:'device' }}">
<input type="hidden" name="action" value="sync_platform">
<button type="submit" class="btn btn-sm btn-outline-primary py-0 px-1" title="Sync platform" aria-label="Sync platform">
<i class="mdi mdi-sync"></i>
</button>
</form>
{% else %}
<span class="text-muted ms-1" title="Platform '{{ sync_info.platform_info.librenms_os }}' not in NetBox">
<i class="mdi mdi-alert-outline"></i>
</span>
{% endif %}
{% elif sync_info and sync_info.platform_synced %}
<span class="text-success"><i class="mdi mdi-check-circle"></i></span>
{% endif %}
{% elif validation.platform.platform %}
{{ validation.platform.platform.name }}
<span class="text-success"><i class="mdi mdi-check-circle"></i></span>
{% elif sync_info and sync_info.platform_info.platform_exists %}
<span class="text-muted">Not set</span>
{% if validation.existing_device %}
<form style="display:inline" class="ms-1"
hx-post="{% url 'plugins:netbox_librenms_plugin:device_conflict_action' device_id=libre_device.device_id %}"
hx-swap="none">
{% csrf_token %}
<input type="hidden" name="existing_device_id" value="{{ validation.existing_device.pk }}">
<input type="hidden" name="existing_device_type" value="{{ existing_device_model_name|default:'device' }}">
<input type="hidden" name="action" value="sync_platform">
<button type="submit" class="btn btn-sm btn-outline-primary py-0 px-1" title="Sync platform" aria-label="Sync platform">
<i class="mdi mdi-sync"></i>
</button>
</form>
{% endif %}
{% else %}
<span class="text-muted">Optional</span>
{% endif %}
</td>
<td>{{ libre_device.os|default:"—" }}</td>
</tr>
{% if validation.import_as_vm and not validation.existing_device or validation.existing_device and existing_device_model_name == "virtualmachine" %}
{# Cluster row (VMs only) #}
<tr>
<td style="padding-left: 0.75rem;">Cluster</td>
<td>
{% if validation.cluster.cluster %}
{{ validation.cluster.cluster.name }}
<span class="text-success"><i class="mdi mdi-check-circle"></i></span>
{% else %}
<span class="text-danger"><i class="mdi mdi-close-circle"></i> No cluster assigned</span>
{% endif %}
</td>
<td><span class="text-muted"></span></td>
</tr>
{% else %}
{# Rack row #}
<tr>
<td style="padding-left: 0.75rem;">Rack</td>
<td>
{% if validation.rack.rack %}
{% if validation.rack.rack.location %}
{{ validation.rack.rack.location.name }} — {{ validation.rack.rack.name }}
{% else %}
{{ validation.rack.rack.name }}
{% endif %}
<span class="text-success"><i class="mdi mdi-check-circle"></i></span>
{% else %}
<span class="text-muted">Optional</span>
{% endif %}
</td>
<td><span class="text-muted"></span></td>
</tr>
{% endif %}
{# Primary IP row #}
<tr>
<td style="padding-left: 0.75rem;">Primary IP</td>
<td>
{% if validation.existing_device and validation.existing_device.primary_ip %}
{{ validation.existing_device.primary_ip }}
{% elif libre_device.ip %}
{{ libre_device.ip }}
{% else %}
<span class="text-muted">No primary IP</span>
{% endif %}
</td>
<td>{{ libre_device.ip|default:"—" }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
{# Status & Actions #}
{% if validation.existing_device %}
{% if validation.existing_match_type == 'librenms_id' %}
<div class="alert alert-info py-2 mb-3 d-flex flex-wrap align-items-center gap-1">
{% if existing_id_servers %}
{% for srv in existing_id_servers %}
<span class="badge bg-info-lt me-1"><i class="mdi mdi-check-decagram"></i> Linked — ID {{ srv.device_id }} @ {{ srv.display_name }}</span>
{% endfor %}
{% else %}
<span class="badge bg-info-lt me-1"><i class="mdi mdi-check-decagram"></i> Linked — ID {{ libre_device.device_id }}</span>
{% endif %}
{% if validation.name_matches %}
<span class="badge bg-success-lt me-1"><i class="mdi mdi-check-circle-outline"></i> Name match</span>
{% elif validation.name_sync_available %}
<span class="badge bg-warning-lt me-1"><i class="mdi mdi-alert"></i> Name differs</span>
{% endif %}
{% if validation.serial_confirmed %}
<span class="badge bg-success-lt me-1"><i class="mdi mdi-check-circle-outline"></i> Serial confirmed</span>
{% elif validation.serial_action == 'conflict' %}
<span class="badge bg-danger-lt me-1"><i class="mdi mdi-alert-circle"></i> Serial conflict</span>
{% elif validation.serial_action == 'update_serial' %}
<span class="badge bg-warning-lt me-1"><i class="mdi mdi-alert"></i> Serial differs</span>
{% endif %}
{% if validation.device_type_mismatch %}
<span class="badge bg-danger-lt me-1"><i class="mdi mdi-alert-circle"></i> Type mismatch</span>
{% endif %}
{% if validation.librenms_id_needs_migration %}
<span class="badge bg-warning-lt me-1"><i class="mdi mdi-database-alert"></i> Legacy ID format</span>
{% endif %}
</div>
{% if validation.librenms_id_needs_migration %}
<div class="mb-3">
<form style="display:inline"
hx-post="{% url 'plugins:netbox_librenms_plugin:device_conflict_action' device_id=libre_device.device_id %}"
hx-swap="none">
{% csrf_token %}
<input type="hidden" name="existing_device_id" value="{{ validation.existing_device.pk }}">
<input type="hidden" name="existing_device_type" value="{{ existing_device_model_name|default:'device' }}">
<input type="hidden" name="server_key" value="{{ server_key }}">
<input type="hidden" name="action" value="migrate_librenms_id">
{% if validation.existing_device.cluster %}
<input type="hidden" name="cluster_{{ libre_device.device_id }}" value="{{ validation.existing_device.cluster.pk }}">
{% endif %}
{% if not validation.serial_confirmed %}
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" name="force" id="force-migrate-{{ libre_device.device_id }}"
onchange="this.closest('form').querySelector('.migrate-btn').disabled = !this.checked">
<label class="form-check-label text-warning" for="force-migrate-{{ libre_device.device_id }}">
<i class="mdi mdi-alert"></i> Serial not confirmed — check to migrate anyway
</label>
</div>
{% endif %}
<button type="submit" class="btn btn-sm btn-warning migrate-btn"{% if not validation.serial_confirmed %} disabled{% endif %}>
<i class="mdi mdi-database-sync"></i> Migrate ID format
</button>
</form>
</div>
{% endif %}
{% elif validation.existing_match_type == 'hostname' %}
<div class="alert alert-warning py-2 mb-3 d-flex flex-wrap align-items-center gap-1">
<span class="badge bg-warning-lt me-1"><i class="mdi mdi-link-variant"></i> Hostname match</span>
{% if validation.serial_confirmed %}
<span class="badge bg-success-lt me-1"><i class="mdi mdi-check-circle-outline"></i> Serial confirmed</span>
{% elif validation.serial_action == 'conflict' %}
<span class="badge bg-danger-lt me-1"><i class="mdi mdi-alert-circle"></i> Serial conflict</span>
{% elif validation.serial_action == 'update_serial' %}
<span class="badge bg-warning-lt me-1"><i class="mdi mdi-alert"></i> Serial differs</span>
{% endif %}
{% if validation.device_type_mismatch %}
<span class="badge bg-danger-lt me-1"><i class="mdi mdi-alert-circle"></i> Type mismatch</span>
{% endif %}
<span class="text-muted ms-1">
— Exists as
<a href="{{ existing_device_url }}" target="_blank" rel="noopener noreferrer">{{ validation.existing_device.name }}</a>,
not linked to LibreNMS.
</span>
</div>
{% if validation.serial_action == 'conflict' %}
<div class="alert alert-danger py-2 mb-3">
<i class="mdi mdi-alert-circle"></i>
Import blocked: The incoming serial number is already assigned to another device in NetBox.
Resolve the duplicate serial before linking.
</div>
{% elif validation.import_as_vm or existing_device_model_name == "virtualmachine" %}
<div class="alert alert-info py-2 mb-3">
<i class="mdi mdi-information-outline"></i>
Hostname match found for a VM — use the import action to proceed.
</div>
{% else %}
<div class="mb-3">
<form style="display:inline"
hx-post="{% url 'plugins:netbox_librenms_plugin:device_conflict_action' device_id=libre_device.device_id %}"
hx-swap="none"
hx-include="#use-sysname-toggle, #strip-domain-toggle">
{% csrf_token %}
<input type="hidden" name="existing_device_id" value="{{ validation.existing_device.pk }}">
<input type="hidden" name="existing_device_type" value="{{ existing_device_model_name|default:'device' }}">
{% if validation.device_type_mismatch %}
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" name="force" id="force-hostname-{{ libre_device.device_id }}"
onchange="this.closest('form').querySelector('.force-action-btn').disabled = !this.checked">
<label class="form-check-label text-danger" for="force-hostname-{{ libre_device.device_id }}">
<i class="mdi mdi-alert-circle"></i> Device type mismatch — check to force
</label>
</div>
{% endif %}
{% if validation.serial_action == 'update_serial' %}
<input type="hidden" name="action" value="update_serial">
<button type="submit" class="btn btn-sm btn-warning{% if validation.device_type_mismatch %} force-action-btn{% endif %}"{% if validation.device_type_mismatch %} disabled{% endif %}>
<i class="mdi mdi-swap-horizontal"></i> Update Serial &amp; Link
</button>
{% else %}
<input type="hidden" name="action" value="link">
<button type="submit" class="btn btn-sm btn-primary{% if validation.device_type_mismatch %} force-action-btn{% endif %}"{% if validation.device_type_mismatch %} disabled{% endif %}>
<i class="mdi mdi-link-plus"></i> Link to LibreNMS
</button>
{% endif %}
</form>
</div>
{% endif %}
{% elif validation.existing_match_type == 'serial' %}
<div class="alert alert-warning py-2 mb-3 d-flex flex-wrap align-items-center gap-1">
<span class="badge bg-warning-lt me-1"><i class="mdi mdi-barcode"></i> Serial match</span>
{% if validation.serial_action == 'hostname_differs' %}
<span class="badge bg-warning-lt me-1"><i class="mdi mdi-alert"></i> Name differs</span>
{% elif validation.serial_action == 'link' %}
<span class="badge bg-success-lt me-1"><i class="mdi mdi-check-circle-outline"></i> Name match</span>
{% endif %}
{% if validation.device_type_mismatch %}
<span class="badge bg-danger-lt me-1"><i class="mdi mdi-alert-circle"></i> Type mismatch</span>
{% endif %}
<span class="text-muted ms-1">
— Exists as
<a href="{{ existing_device_url }}" target="_blank" rel="noopener noreferrer">{{ validation.existing_device.name }}</a>,
not linked to LibreNMS.
</span>
</div>
<div class="mb-3">
<form style="display:inline"
hx-post="{% url 'plugins:netbox_librenms_plugin:device_conflict_action' device_id=libre_device.device_id %}"
hx-swap="none"
hx-include="#use-sysname-toggle, #strip-domain-toggle">
{% csrf_token %}
<input type="hidden" name="existing_device_id" value="{{ validation.existing_device.pk }}">
<input type="hidden" name="existing_device_type" value="{{ existing_device_model_name|default:'device' }}">
{% if validation.device_type_mismatch %}
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" name="force" id="force-serial-{{ libre_device.device_id }}"
onchange="this.closest('form').querySelector('.force-action-btn').disabled = !this.checked">
<label class="form-check-label text-danger" for="force-serial-{{ libre_device.device_id }}">
<i class="mdi mdi-alert-circle"></i> Device type mismatch — check to force
</label>
</div>
{% endif %}
{% if validation.serial_action == 'link' %}
<input type="hidden" name="action" value="link">
<button type="submit" class="btn btn-sm btn-primary{% if validation.device_type_mismatch %} force-action-btn{% endif %}"{% if validation.device_type_mismatch %} disabled{% endif %}>
<i class="mdi mdi-link-plus"></i> Link to LibreNMS
</button>
{% elif validation.serial_action == 'hostname_differs' %}
<button type="submit" name="action" value="update" class="btn btn-sm btn-warning{% if validation.device_type_mismatch %} force-action-btn{% endif %}"{% if validation.device_type_mismatch %} disabled{% endif %}>
<i class="mdi mdi-pencil"></i> Update &amp; Link
</button>
{% endif %}
</form>
</div>
{% elif validation.existing_match_type == 'primary_ip' %}
<div class="alert alert-warning py-2 mb-3 d-flex flex-wrap align-items-center gap-1">
<span class="badge bg-warning-lt me-1"><i class="mdi mdi-ip-network"></i> IP match</span>
<span class="text-muted ms-1">
— Device with IP {{ libre_device.ip }} exists as
<a href="{{ existing_device_url }}" target="_blank" rel="noopener noreferrer">{{ validation.existing_device.name }}</a>.
Consider adding LibreNMS ID manually.
</span>
</div>
{% else %}
<div class="alert alert-info py-2 mb-3">
<i class="mdi mdi-link"></i>
<strong>Exists</strong> — Device already exists as
<a href="{{ existing_device_url }}" target="_blank" rel="noopener noreferrer">{{ validation.existing_device.name }}</a>.
</div>
{% endif %}
{% elif validation.is_ready %}
<div class="alert alert-success py-2 mb-3">
<i class="mdi mdi-check-circle"></i>
<strong>Ready to Import</strong> — All prerequisites are met.
</div>
{% elif validation.can_import %}
<div class="alert alert-warning py-2 mb-3">
<i class="mdi mdi-alert"></i>
<strong>Can Import</strong> — Review warnings below before importing.
</div>
{% else %}
<div class="alert alert-danger py-2 mb-3">
<i class="mdi mdi-close-circle"></i>
<strong>Cannot Import</strong> — Validation issues prevent import.
</div>
{% endif %}
{# Warnings #}
{% if validation.warnings %}
<div class="card mb-3">
<div class="card-header py-2">
<i class="mdi mdi-alert"></i> Warnings ({{ validation.warnings|length }})
</div>
<div class="card-body py-2">
<ul class="mb-0 ps-3">
{% for warning in validation.warnings %}
<li><small>{{ warning }}</small></li>
{% endfor %}
</ul>
</div>
</div>
{% endif %}
</div>
<div class="modal-footer">
{% if validation.existing_device %}
{% if existing_device_model_name == "virtualmachine" %}
<a href="{{ existing_device_url }}"
class="btn btn-primary btn-sm" target="_blank" rel="noopener noreferrer">
<i class="mdi mdi-open-in-new"></i> View VM in NetBox
</a>
{% else %}
<a href="{{ existing_device_url }}"
class="btn btn-primary btn-sm" target="_blank" rel="noopener noreferrer">
<i class="mdi mdi-open-in-new"></i> View in NetBox
</a>
{% if validation.existing_match_type == 'librenms_id' %}
<a href="{% url 'plugins:netbox_librenms_plugin:device_librenms_sync' pk=validation.existing_device.pk %}"
class="btn btn-outline-primary btn-sm" target="_blank" rel="noopener noreferrer">
<i class="mdi mdi-sync"></i> Full Sync Page
</a>
{% endif %}
{% endif %}
{% endif %}
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">
<i class="mdi mdi-close"></i> Close
</button>
</div>

View File

@@ -0,0 +1,121 @@
{# HTMX template for virtual chassis details modal #}
{# Shows virtual chassis/stack information for a LibreNMS device #}
<div class="modal-header">
<h5 class="modal-title">
<i class="mdi mdi-server-network"></i> Virtual Chassis: {{ libre_device.hostname }}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
{# Device Summary #}
<div class="card mb-3">
<div class="card-header">
<i class="mdi mdi-server"></i> LibreNMS Device Information
</div>
<div class="card-body">
<dl class="row mb-0">
<dt class="col-sm-3">Hostname:</dt>
<dd class="col-sm-9"><strong>{{ libre_device.hostname }}</strong></dd>
<dt class="col-sm-3">System Name:</dt>
<dd class="col-sm-9">{{ libre_device.sysName|default:"N/A" }}</dd>
<dt class="col-sm-3">LibreNMS ID:</dt>
<dd class="col-sm-9">{{ libre_device.device_id }}</dd>
<dt class="col-sm-3">Hardware:</dt>
<dd class="col-sm-9">{{ libre_device.hardware|default:"N/A" }}</dd>
<dt class="col-sm-3">OS:</dt>
<dd class="col-sm-9">{{ libre_device.os|default:"N/A" }}</dd>
</dl>
</div>
</div>
{# Virtual Chassis Detection Error #}
{% if vc_data.detection_error %}
<div class="alert alert-warning">
<h6 class="alert-heading">
<i class="mdi mdi-alert"></i> Virtual Chassis Detection Error
</h6>
<p class="mb-0">
Unable to detect virtual chassis information: <strong>{{ vc_data.detection_error }}</strong>
</p>
</div>
{% endif %}
{# Virtual Chassis Details #}
{% if vc_data.is_stack %}
<div class="card mb-3">
<div class="card-header">
<i class="mdi mdi-switch"></i> Virtual Chassis Stack
</div>
<div class="card-body">
<p class="mb-3">
This device is part of a <strong>{{ vc_data.member_count }}-member</strong> stack/virtual chassis.
</p>
{% if vc_data.members %}
<h6 class="mb-3">Stack Members:</h6>
<div class="border rounded">
<div class="px-3 pb-2">
<ul class="list-unstyled mb-0 small">
{% for member in vc_data.members %}
<li class="py-3 {% if not forloop.last %}border-bottom{% endif %}">
<div class="d-flex flex-wrap align-items-center gap-2">
<span class="text-muted me-2">
{% if member.position %}
Pos {{ member.position }}
{% else %}
Pos —
{% endif %}
</span>
<code class="flex-grow-1 mb-0">{{ member.suggested_name|default:member.name|default:"(unknown)" }}</code>
{% if member.is_master %}
<span class="badge bg-success text-white">Master</span>
{% endif %}
</div>
<div class="text-muted mt-2 d-flex flex-wrap gap-3">
{% if member.model %}
<span><strong>Model:</strong> {{ member.model }}</span>
{% endif %}
{% if member.serial %}
<span><strong>Serial:</strong> <code>{{ member.serial }}</code></span>
{% endif %}
{% if not member.model and not member.serial %}
<span>No additional metadata</span>
{% endif %}
</div>
</li>
{% endfor %}
</ul>
</div>
</div>
{% else %}
<p class="text-muted mb-0">
<i class="mdi mdi-information-outline"></i> No member details available.
</p>
{% endif %}
</div>
</div>
{% elif not vc_data.detection_error %}
<div class="alert alert-info">
<h6 class="alert-heading">
<i class="mdi mdi-information-outline"></i> Not a Virtual Chassis
</h6>
<p class="mb-0">
This device does not appear to be part of a virtual chassis or stack.
</p>
</div>
{% endif %}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
<i class="mdi mdi-close"></i> Close
</button>
</div>

View File

@@ -0,0 +1,59 @@
{% load i18n %}
{% if table.page %}
{% with page_param=table.prefix|stringformat:"s"|add:"page" %}
<div class="d-flex justify-content-between align-items-center border-{% if placement == 'top' %}bottom{% else %}top{% endif %} p-2">
{% if table.paginator.num_pages > 1 %}
<nav aria-label="{% trans "Page selection" %}">
<ul class="pagination mb-0">
{% if table.page.has_previous %}
<li class="page-item">
<a href="?tab={{ table.tab }}&{{ page_param }}={{ table.page.previous_page_number }}&{{ table.prefix }}per_page={{ table.paginator.per_page }}&interface_name_field={{ interface_name_field }}" class="page-link">
<i class="mdi mdi-chevron-left"></i>
</a>
</li>
{% endif %}
{% for p in table.page.smart_pages %}
<li class="page-item{% if table.page.number == p %} active{% endif %}">
{% if p %}
<a href="?tab={{ table.tab }}&{{ page_param }}={{ p }}&{{ table.prefix }}per_page={{ table.paginator.per_page }}&interface_name_field={{ interface_name_field }}" class="page-link">{{ p }}</a>
{% else %}
<span class="page-link" disabled>&hellip;</span>
{% endif %}
</li>
{% endfor %}
{% if table.page.has_next %}
<li class="page-item">
<a href="?tab={{ table.tab }}&{{ page_param }}={{ table.page.next_page_number }}&{{ table.prefix }}per_page={{ table.paginator.per_page }}&interface_name_field={{ interface_name_field }}" class="page-link">
<i class="mdi mdi-chevron-right"></i>
</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
<small class="text-end text-muted">
{% blocktrans trimmed with start=table.page.start_index end=table.page.end_index total=table.paginator.count %}
Showing {{ start }}-{{ end }} of {{ total }}
{% endblocktrans %}
</small>
<nav class="text-end" aria-label="{% trans "Pagination options" %}">
<div class="dropdown">
<button class="btn btn-sm btn-outline-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown">
{% trans "Per Page" %}
</button>
<div class="dropdown-menu">
{% for n in table.paginator.get_page_lengths %}
<a href="?tab={{ table.tab }}&{{ table.prefix }}per_page={{ n }}&{{ page_param }}={{ table.page.number }}&interface_name_field={{ interface_name_field }}" class="dropdown-item">{{ n }}</a>
{% endfor %}
</div>
</div>
</nav>
</div>
{% endwith %}
{% endif %}

View File

@@ -0,0 +1,30 @@
{% extends 'generic/object.html' %}
{% load helpers %}
{% load plugins %}
{% block content %}
<div class="row mb-3">
<div class="col col-md-12">
<div class="card">
<table class="table table-hover attr-table">
<thead>
<tr>
<th>LibreNMS Type</th>
<th>LibreNMS Speed (Kbps)</th>
<th>NetBox Type</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>{{ object.librenms_type }}</td>
<td>{{ object.librenms_speed }}</td>
<td>{{ object.get_netbox_type_display }}</td>
<td>{{ object.description|default:"—" }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,12 @@
{% extends 'generic/object_list.html' %}
{% block content %}
<div class="alert alert-info">
<h4>Interface Type Mapping</h4>
<p>This section allows you to map LibreNMS interface types to NetBox interface types.
When synchronizing interfaces from LibreNMS, these mappings will be used to ensure
correct interface type assignment in NetBox.</p>
<p>Example: Map LibreNMS type "ethernetCsmacd" to NetBox type "1000base-t"</p>
</div>
{{ block.super }}
{% endblock %}

View File

@@ -0,0 +1,438 @@
{% extends 'generic/_base.html' %}
{% load buttons %}
{% load helpers %}
{% load plugins %}
{% load render_table from django_tables2 %}
{% load static %}
{% load i18n %}
{% block title %}{{ title }}{% endblock %}
{% block javascript %}
{{ block.super }}
<script src="{% static 'netbox_librenms_plugin/js/librenms_import.js' %}"></script>
{% endblock %}
{% block controls %}
<div class="btn-list">
{% plugin_list_buttons model %}
{% action_buttons actions model %}
</div>
{% endblock controls %}
{% block content %}
{# Display Django messages #}
{% include 'inc/messages.html' %}
{# LibreNMS Server Information #}
{% if librenms_server_info %}
<div class="card mb-3">
<div class="card-body p-2">
<div class="d-flex justify-content-between align-items-center">
<div>
<i class="mdi mdi-server-network text-primary"></i>
<strong>Active LibreNMS Server:</strong>
{% if librenms_server_info.is_legacy %}
{{ librenms_server_info.url }}
{% else %}
{{ librenms_server_info.display_name }} ({{ librenms_server_info.url }})
{% endif %}
</div>
{% if not librenms_server_info.is_legacy %}
<a href="{% url 'plugins:netbox_librenms_plugin:settings' %}" class="btn btn-sm btn-outline-primary ms-3">
<i class="mdi mdi-cog"></i> Change Server
</a>
{% endif %}
</div>
</div>
</div>
{% endif %}
{# Collapsible Filter Section #}
{% if filter_form %}
<div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0">
Search Filters
{% if filter_form.changed_data %}
{% badge filter_form.changed_data|length bg_color="primary" %}
{% endif %}
</h5>
<button class="btn btn-sm btn-outline-secondary" type="button" data-bs-toggle="collapse" data-bs-target="#filter-collapse" aria-expanded="{% if filter_form.changed_data %}false{% else %}true{% endif %}" aria-controls="filter-collapse">
<i class="mdi mdi-filter"></i> Toggle Filters
</button>
</div>
<div class="collapse {% if not filter_form.changed_data %}show{% endif %}" id="filter-collapse">
<div class="card-body">
<div class="row">
{# Instructions column #}
<div class="col-md-4">
<h6 class="mb-3"><i class="mdi mdi-information-outline"></i> Search Instructions</h6>
<p class="mb-3">
<strong>At least one filter is required</strong> to search for devices.
</p>
<p class="mb-3">
The following matching rules apply:
<ul class="mb-3" style="padding-left: 1.2rem; font-size: 0.9rem;">
<li><strong>Location/Type/OS:</strong> Exact match</li>
<li><strong>Hostname:</strong> Partial match</li>
<li><strong>System Name:</strong> Exact match (or partial when combined with other filters)</li>
</ul>
<div class="alert alert-info small mt-2 mb-0" role="status">
<ul class="mb-0" style="padding-left: 1.2rem;">
<strong>Performance note:</strong> <li>Results are cached (default: 5 minutes).</li>
<li>Large LibreNMS datasets take time to process</li>
<li>Repeating the same filter search will use cached data if available.</li>
<li>Using background jobs is default and recommended.</li>
<li>Background jobs can be cancelled if needed.</li>
</ul>
</div>
<p class="mt-3 text-muted small">
<i class="mdi mdi-lightbulb-on-outline"></i> <strong>Tip:</strong> Start with Location and/or Type to narrow results, then refine with additional filters.
</p>
</div>
{# Filters column #}
<div class="col-md-8">
<form method="get" class="form" id="librenms-import-filter-form">
<input type="hidden" name="apply_filters" value="1">
{% if show_filter_warning %}
<div class="alert alert-warning alert-dismissible fade show border-warning" role="alert" style="border-left: 4px solid #ffc107;">
<i class="mdi mdi-alert text-warning"></i>
<strong>Filter Required:</strong> {{ filter_warning }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endif %}
<div class="row mb-3">
<div class="col-md-6">
<label for="{{ filter_form.librenms_location.id_for_label }}" class="form-label">{{ filter_form.librenms_location.label }}</label>
{{ filter_form.librenms_location }}
</div>
<div class="col-md-6">
<label for="{{ filter_form.librenms_type.id_for_label }}" class="form-label">{{ filter_form.librenms_type.label }}</label>
{{ filter_form.librenms_type }}
</div>
</div>
<div class="row mb-3">
<div class="col-md-6">
<label for="{{ filter_form.librenms_os.id_for_label }}" class="form-label">{{ filter_form.librenms_os.label }}</label>
{{ filter_form.librenms_os }}
{% if filter_form.librenms_os.help_text %}
<small class="form-text text-muted">{{ filter_form.librenms_os.help_text }}</small>
{% endif %}
</div>
<div class="col-md-6">
<label for="{{ filter_form.librenms_hostname.id_for_label }}" class="form-label">{{ filter_form.librenms_hostname.label }}</label>
{{ filter_form.librenms_hostname }}
{% if filter_form.librenms_hostname.help_text %}
<small class="form-text text-muted">{{ filter_form.librenms_hostname.help_text }}</small>
{% endif %}
</div>
</div>
<div class="row mb-3">
<div class="col-md-6">
<label for="{{ filter_form.librenms_sysname.id_for_label }}" class="form-label">{{ filter_form.librenms_sysname.label }}</label>
{{ filter_form.librenms_sysname }}
{% if filter_form.librenms_sysname.help_text %}
<small class="form-text text-muted">{{ filter_form.librenms_sysname.help_text }}</small>
{% endif %}
</div>
<div class="col-md-6">
<label for="{{ filter_form.librenms_hardware.id_for_label }}" class="form-label">{{ filter_form.librenms_hardware.label }}</label>
{{ filter_form.librenms_hardware }}
{% if filter_form.librenms_hardware.help_text %}
<small class="form-text text-muted">{{ filter_form.librenms_hardware.help_text }}</small>
{% endif %}
</div>
</div>
<div class="row mb-3">
<div class="col-md-6">
<div class="form-check">
{{ filter_form.exclude_existing }}
<label class="form-check-label" for="{{ filter_form.exclude_existing.id_for_label }}">
{{ filter_form.exclude_existing.label }}
</label>
{% if filter_form.exclude_existing.help_text %}
<small class="form-text text-muted d-block">{{ filter_form.exclude_existing.help_text }}</small>
{% endif %}
</div>
</div>
<div class="col-md-6">
<div class="form-check">
{{ filter_form.show_disabled }}
<label class="form-check-label" for="{{ filter_form.show_disabled.id_for_label }}">
{{ filter_form.show_disabled.label }}
</label>
{% if filter_form.show_disabled.help_text %}
<small class="form-text text-muted d-block">{{ filter_form.show_disabled.help_text }}</small>
{% endif %}
</div>
</div>
</div>
<div class="row mb-3">
<div class="col-md-6">
<div class="form-check">
{{ filter_form.enable_vc_detection }}
<label class="form-check-label" for="{{ filter_form.enable_vc_detection.id_for_label }}">
{{ filter_form.enable_vc_detection.label }}
</label>
{% if filter_form.enable_vc_detection.help_text %}
<small class="form-text text-muted d-block">{{ filter_form.enable_vc_detection.help_text }}</small>
{% endif %}
</div>
</div>
<div class="col-md-6">
<div class="form-check">
{{ filter_form.clear_cache }}
<label class="form-check-label" for="{{ filter_form.clear_cache.id_for_label }}">
{{ filter_form.clear_cache.label }}
</label>
{% if filter_form.clear_cache.help_text %}
<small class="form-text text-muted d-block">{{ filter_form.clear_cache.help_text }}</small>
{% endif %}
</div>
</div>
</div>
<div class="p-3 my-3 bg-primary-subtle border border-primary rounded-1">
<div class="form-check">
{% if can_use_background_jobs %}
{{ filter_form.use_background_job }}
<label class="form-check-label" for="{{ filter_form.use_background_job.id_for_label }}">
{{ filter_form.use_background_job.label }}
</label>
{% if filter_form.use_background_job.help_text %}
<small class="form-text text-muted d-block">{{ filter_form.use_background_job.help_text }}</small>
{% endif %}
{% else %}
<input type="checkbox" class="form-check-input" disabled title="Background jobs require superuser access">
<label class="form-check-label text-muted">
{{ filter_form.use_background_job.label }}
</label>
<small class="form-text text-muted d-block">
<i class="mdi mdi-information-outline"></i> Background jobs require superuser access. Filters will process synchronously.
</small>
{% endif %}
</div>
</div>
<div class="text-end">
<button type="submit" class="btn btn-primary" id="apply-filters-btn">
<i class="mdi mdi-filter"></i> Apply Filters
</button>
<a href="{% url 'plugins:netbox_librenms_plugin:librenms_import' %}" class="btn btn-secondary">
<i class="mdi mdi-close"></i> Clear
</a>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
{% endif %}
{# Cached Searches Section #}
{% if cached_searches %}
<div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="card-title mb-0">
Active Cached Searches
<span id="cached-searches-badge">{% badge cached_searches|length bg_color="primary" %}</span>
</h6>
<button class="btn btn-sm btn-outline-secondary" type="button" data-bs-toggle="collapse" data-bs-target="#cached-searches-collapse" aria-expanded="true" aria-controls="cached-searches-collapse">
<i class="mdi mdi-clock-fast"></i> Toggle Cached Searches
</button>
</div>
<div class="collapse show" id="cached-searches-collapse">
<div class="card-body p-2">
<div class="d-flex flex-wrap gap-2">
{% for search in cached_searches %}
<a href="?apply_filters=1{% for key, value in search.filters.items %}&librenms_{{ key }}={{ value|urlencode }}{% endfor %}{% if search.vc_enabled %}&enable_vc_detection=1{% endif %}"
class="btn btn-sm btn-outline-secondary"
style="text-decoration: none;"
title="Click to load this cached search">
<i class="mdi mdi-filter-outline"></i>
{% with display_data=search.display_filters|default:search.filters %}
{% for key, value in display_data.items %}
{% if not forloop.first %} <span class="text-muted">|</span> {% endif %}
{{ value }}
{% endfor %}
{% endwith %}
{% if search.vc_enabled %}
<span class="text-muted">| VC</span>
{% endif %}
<span class="text-muted ms-2">({{ search.device_count }} devices, <span class="cached-search-countdown" data-cache-timestamp="{{ search.cached_at }}" data-cache-timeout="{{ search.cache_timeout }}">{{ search.remaining_seconds }}s</span> left)</span>
</a>
{% endfor %}
</div>
<small class="text-muted d-block mt-2">
<i class="mdi mdi-information-outline"></i> Click any cached search to load those results again.
</small>
</div>
</div>
</div>
{% endif %}
{# Applied filters #}
{% if filter_form %}
{% applied_filters model filter_form request.GET %}
{% endif %}
<style>
/* Allow dropdown menus to extend beyond table container */
.device-import-table-wrapper {
overflow-x: auto;
overflow-y: visible;
/* Add space below table for dropdowns in last rows */
padding-bottom: 200px;
/* Pull up pagination to remove visual gap */
margin-bottom: -200px;
}
/* Ensure card doesn't clip overflowing dropdowns */
.device-import-table-wrapper + .pagination {
position: relative;
z-index: 1;
}
</style>
{# Results Section #}
{% if table.page.paginator.count == 0 %}
<div class="alert alert-warning mb-3">
<h6 class="alert-heading">
<i class="mdi mdi-alert"></i> No Results Found
</h6>
<p class="mb-0">
No devices found matching your filters. Try adjusting your search criteria above.
</p>
</div>
{% else %}
{# Bulk import toolbar #}
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-3">
<div class="d-flex align-items-center gap-2 flex-wrap">
<button type="button" class="btn btn-outline-primary" id="select-all-ready">
<i class="mdi mdi-checkbox-multiple-marked"></i> Select All Ready
</button>
<button type="button" class="btn btn-outline-secondary" id="select-none">
<i class="mdi mdi-checkbox-multiple-blank-outline"></i> Deselect All
</button>
<span class="text-muted" id="selection-count">
<i class="mdi mdi-information"></i> 0 devices selected
</span>
</div>
<div class="d-flex align-items-center gap-2 flex-wrap">
{# Cache expiration info - before Settings section #}
{% if filters_submitted and table.rows %}
{% if cache_metadata_missing %}
<div class="d-flex align-items-center gap-1 text-warning small" title="Cache metadata not found. This may indicate a cache synchronization issue.">
<i class="mdi mdi-alert"></i>
<span>Cache status: <strong>unavailable</strong></span>
</div>
<span class="text-muted">|</span>
{% elif cache_timestamp and cache_timeout %}
<div class="d-flex align-items-center gap-1 text-muted small" id="cache-info-display"
data-cache-timestamp="{{ cache_timestamp }}"
data-cache-timeout="{{ cache_timeout }}">
<i class="mdi mdi-clock-outline"></i>
<span>Cache expires in <strong id="cache-expiry-countdown">{{ cache_timeout }}s</strong></span>
</div>
<span class="text-muted">|</span>
{% endif %}
{% endif %}
{# Import Settings #}
<div class="d-flex align-items-center gap-2 border-start ps-2"
data-save-pref-url="{% url 'plugins:netbox_librenms_plugin:save_user_pref' %}">
<strong class="small">Settings:</strong>
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" id="use-sysname-toggle" name="use-sysname-toggle" {% if use_sysname %}checked{% endif %}
data-bs-toggle="tooltip"
title="Use SNMP sysName instead of LibreNMS hostname">
<label class="form-check-label small" for="use-sysname-toggle">
Use sysName
</label>
</div>
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" id="strip-domain-toggle" name="strip-domain-toggle" {% if strip_domain %}checked{% endif %}
data-bs-toggle="tooltip"
title="Remove domain suffix (e.g., 'switch01.example.com' → 'switch01'). IP addresses preserved.">
<label class="form-check-label small" for="strip-domain-toggle">
Strip domain
</label>
</div>
</div>
<button type="button" class="btn btn-success" id="bulk-import-btn" disabled
hx-post="{% url 'plugins:netbox_librenms_plugin:bulk_import_confirm' %}"
hx-target="#htmx-modal-content"
hx-swap="innerHTML"
hx-include="#import-selection-form, #use-sysname-toggle, #strip-domain-toggle">
<span class="d-inline-flex align-items-center gap-1">
<i class="mdi mdi-download"></i>
<span>Import Selected (<span id="import-count">0</span>)</span>
</span>
<span class="spinner-border spinner-border-sm ms-2 htmx-indicator" role="status" aria-hidden="true" style="display: none;"></span>
</button>
</div>
</div>
{% endif %}
<form method="post" class="form form-horizontal" id="import-selection-form">
{% csrf_token %}
<input type="hidden" name="return_url" value="{% if return_url %}{{ return_url }}{% else %}{{ request.path }}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}{% endif %}" />
<input type="hidden" name="enable_vc_detection" value="{{ vc_detection_enabled|yesno:'true,false' }}" />
{# Objects table #}
<div class="card">
<div class="htmx-container" id="object_list">
{% include 'inc/paginator.html' with htmx=True table=table paginator=table.paginator page=table.page %}
<div class="table-responsive device-import-table-wrapper">
{% include 'inc/table.html' %}
</div>
{% include 'inc/paginator.html' with htmx=True table=table paginator=table.paginator page=table.page %}
</div>
</div>
{# /Objects table #}
</form>
{# HTMX Modal for import actions #}
<div class="modal fade" id="htmx-modal" tabindex="-1" aria-labelledby="htmxModalLabel" aria-hidden="true">
<div class="modal-dialog modal-xl">
<div class="modal-content" id="htmx-modal-content">
{# Content loaded via HTMX #}
<div class="modal-body text-center">
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
</div>
</div>
</div>
{# Filter Processing Modal #}
<div class="modal fade" id="filter-processing-modal" tabindex="-1" aria-labelledby="filterProcessingLabel" aria-hidden="true" data-bs-backdrop="static" data-bs-keyboard="false">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-body text-center py-4">
<div class="spinner-border text-primary mb-3" style="width: 3rem; height: 3rem;" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<h5 class="mb-2">Applying Filters</h5>
<p class="text-muted mb-1" id="filter-progress-message">Fetching LibreNMS data and processing filters...</p>
<p class="text-info mb-3" id="filter-device-count" style="display: none;">
<strong>LibreNMS Devices:</strong> <span id="filter-device-count-value">0</span>
</p>
{% if can_use_background_jobs %}
<button type="button" class="btn btn-secondary btn-sm" id="cancel-filter-btn">
<i class="mdi mdi-close"></i> Cancel
</button>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,339 @@
{% extends 'generic/object_edit.html' %}
{% load form_helpers %}
{% block title %}Plugin Settings{% endblock %}
{% block tabs %}
<ul class="nav nav-tabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="config-tab" data-bs-toggle="tab" data-bs-target="#config" type="button" role="tab" aria-controls="config" aria-selected="true">
<i class="ti ti-server-2 me-1"></i> Server Config
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="import-settings-tab" data-bs-toggle="tab" data-bs-target="#import-settings" type="button" role="tab" aria-controls="import-settings" aria-selected="false">
<i class="ti ti-download me-1"></i> Import Settings
</button>
</li>
</ul>
{% endblock tabs %}
{% block content %}
<div class="tab-content" data-active-tab="{{ active_tab|default:'' }}">
<!-- Server Config Tab -->
<div class="tab-pane fade show active" id="config" role="tabpanel" aria-labelledby="config-tab">
<form action="" method="post" class="form" enctype="multipart/form-data">
{% csrf_token %}
<input type="hidden" name="form_type" value="server_config">
<div class="row justify-content-start">
<div class="col-lg-5 col-xl-4">
<div class="card h-100">
<div class="card-header">
<h3 class="card-title">
<i class="ti ti-server-2 me-2"></i>
LibreNMS Server Settings
</h3>
</div>
<div class="card-body d-flex flex-column">
<div class="mb-3 flex-grow-1">
<p class="text-muted mb-3">
Configure which LibreNMS server to use for synchronization operations.
Multiple servers can be configured in the NetBox configuration file.
</p>
{% render_field server_form.selected_server %}
</div>
<div class="text-end mt-auto">
<button type="submit" id="save-server-btn" class="btn btn-primary" disabled>
<i class="ti ti-check me-1"></i> Save Server Config
</button>
</div>
</div>
</div>
</div>
<!-- Connection Test Card -->
<div class="col-lg-5 col-xl-4">
<div class="card h-100">
<div class="card-header">
<h3 class="card-title">
<i class="ti ti-network me-2"></i>
Connection Test
</h3>
</div>
<div class="card-body d-flex flex-column">
<div class="flex-grow-1">
<p class="text-muted mb-3">Test the connection to the selected LibreNMS server to verify configuration.</p>
<button type="button"
id="test-connection-btn"
class="btn btn-outline-info w-100"
hx-post="{% url 'plugins:netbox_librenms_plugin:test_connection' %}"
hx-target="#test-result"
hx-swap="innerHTML"
hx-include="[name='selected_server']">
<i class="ti ti-network me-1"></i> Test Connection
<span class="spinner-border spinner-border-sm ms-1 htmx-indicator" role="status" style="display: none;"></span>
</button>
<!-- Test result area -->
<div id="test-result" class="mt-3"></div>
</div>
</div>
</div>
</div>
</div>
<!-- Spacing between sections -->
<div class="my-4"></div>
<!-- Configuration Example Section -->
<div class="row">
<div class="col-lg-10 col-xl-8">
<div class="card">
<div class="card-header">
<h3 class="card-title">
<i class="ti ti-code me-2"></i>
Configuration Example
</h3>
</div>
<div class="card-body">
<p class="mb-3">
To configure multiple LibreNMS servers, update your NetBox
<code>configuration.py</code>:
</p>
<div class="highlight">
<pre class="mb-3"><code>PLUGINS_CONFIG = {
'netbox_librenms_plugin': {
'servers': {
'production': {
'display_name': 'Production LibreNMS',
'librenms_url': 'https://librenms-prod.example.com',
'api_token': 'your_production_token',
'cache_timeout': 300,
'verify_ssl': True,
'interface_name_field': 'ifDescr'
},
'testing': {
'display_name': 'Test LibreNMS',
'librenms_url': 'https://librenms-test.example.com',
'api_token': 'your_test_token',
'cache_timeout': 300,
'verify_ssl': False,
'interface_name_field': 'ifName'
}
}
}
}</code></pre>
</div>
<div class="alert alert-info">
<i class="ti ti-info-circle me-2"></i>
<strong>Note:</strong> For backward compatibility, the legacy single-server configuration
is still supported if no "servers" configuration is provided.
</div>
</div>
</div>
</div>
</div>
</form>
</div>
<!-- Import Settings Tab -->
<div class="tab-pane fade" id="import-settings" role="tabpanel" aria-labelledby="import-settings-tab">
<form action="" method="post" class="form" enctype="multipart/form-data">
{% csrf_token %}
<input type="hidden" name="form_type" value="import_settings">
<div class="row justify-content-start">
<!-- Left Column: Device Naming and Virtual Chassis -->
<div class="col-lg-6">
<!-- Device Naming Defaults Card -->
<div class="card mb-3">
<div class="card-header">
<h3 class="card-title">
<i class="ti ti-edit me-2"></i>
Device Naming Defaults
</h3>
</div>
<div class="card-body">
<p class="text-muted mb-3">
Configure default naming preferences for imported devices.
</p>
<div class="alert alert-info small mb-3">
<i class="ti ti-info-circle me-1"></i>
<strong>User preferences:</strong> These defaults apply to users who have not yet changed their own toggle settings on the import page. Once a user changes a toggle, their personal preference is saved and takes priority over these defaults.
Saving these settings will also update your own preferences to match.
</div>
<div class="row">
<div class="col-12">
<div class="form-group mb-3">
<div class="form-check form-switch">
{{ import_form.use_sysname_default }}
<label class="form-check-label" for="{{ import_form.use_sysname_default.id_for_label }}">
{{ import_form.use_sysname_default.label }}
</label>
{% if import_form.use_sysname_default.help_text %}
<small class="form-text text-muted d-block">{{ import_form.use_sysname_default.help_text }}</small>
{% endif %}
</div>
</div>
<div class="form-group mb-3">
<div class="form-check form-switch">
{{ import_form.strip_domain_default }}
<label class="form-check-label" for="{{ import_form.strip_domain_default.id_for_label }}">
{{ import_form.strip_domain_default.label }}
</label>
{% if import_form.strip_domain_default.help_text %}
<small class="form-text text-muted d-block">{{ import_form.strip_domain_default.help_text }}</small>
{% endif %}
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Virtual Chassis Card -->
<div class="card mb-3">
<div class="card-header">
<h3 class="card-title">
<i class="ti ti-switch me-2"></i>
Virtual Chassis Member Naming
</h3>
</div>
<div class="card-body">
<p class="text-muted mb-3">
Configure how virtual chassis member devices are named during import.
</p>
<div class="row">
<div class="col-12">
<div class="form-group mb-3">
<label for="{{ import_form.vc_member_name_pattern.id_for_label }}" class="form-label">
{{ import_form.vc_member_name_pattern.label }}
</label>
{{ import_form.vc_member_name_pattern }}
{% if import_form.vc_member_name_pattern.errors %}
<div class="invalid-feedback d-block">
{{ import_form.vc_member_name_pattern.errors }}
</div>
{% endif %}
</div>
</div>
</div>
<div class="row">
<div class="col-12">
<div class="alert alert-info mb-0">
<p class="small mb-2"><strong>Available placeholders:</strong></p>
<ul class="mb-3 small" style="padding-left: 1.2rem;">
<li><code>{position}</code> - VC position number</li>
<li><code>{serial}</code> - Member serial number</li>
</ul>
<p class="small mb-0"><strong>Note:</strong> The pattern is appended to the master device name. At least one placeholder is required to ensure each member gets a unique name.</p>
</div>
</div>
</div>
<div class="row">
<div class="col-12">
<p class="small mb-2"><strong>Examples:</strong></p>
<ul class="mb-0 small" style="padding-left: 1.2rem;">
<li><code>-M{position}</code> → switch01-M1, switch01-M2</li>
<li><code> ({position})</code> → switch01 (1), switch01 (2)</li>
<li><code>-SW{position}</code> → switch01-SW1, switch01-SW2</li>
<li><code> [{serial}]</code> → switch01 [ABC123], switch01 [ABC124]</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Save Button -->
<div class="row justify-content-start">
<div class="col-lg-6">
<div class="text-end">
<button type="submit" id="save-import-btn" class="btn btn-primary" disabled>
<i class="ti ti-check me-1"></i> Save Import Settings
</button>
</div>
</div>
</div>
</form>
</div>
</div>
<!-- Spacing -->
<div class="my-4"></div>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Auto-select the correct tab if there was a validation error
// Uses the same pattern as librenms_sync.js for consistency
const tabContent = document.querySelector('.tab-content');
const activeTab = tabContent ? tabContent.dataset.activeTab : '';
if (activeTab === 'import_settings') {
const importTab = document.getElementById('import-settings-tab');
const importPane = document.getElementById('import-settings');
const configTab = document.getElementById('config-tab');
const configPane = document.getElementById('config');
if (importTab && importPane && configTab && configPane) {
// Deactivate config tab
configTab.classList.remove('active');
configTab.setAttribute('aria-selected', 'false');
configPane.classList.remove('show', 'active');
// Activate import settings tab
importTab.classList.add('active');
importTab.setAttribute('aria-selected', 'true');
importPane.classList.add('show', 'active');
}
}
const serverSelect = document.getElementById('id_selected_server');
const vcPatternInput = document.getElementById('id_vc_member_name_pattern');
const saveServerBtn = document.getElementById('save-server-btn');
const saveImportBtn = document.getElementById('save-import-btn');
const useSysnameCheckbox = document.getElementById('id_use_sysname_default');
const stripDomainCheckbox = document.getElementById('id_strip_domain_default');
// Store the initial values to detect changes
const initialServerValue = serverSelect.value;
const initialVcPatternValue = vcPatternInput.value;
const initialUseSysnameValue = useSysnameCheckbox ? useSysnameCheckbox.checked : true;
const initialStripDomainValue = stripDomainCheckbox ? stripDomainCheckbox.checked : false;
// Enable/disable server save button based on changes
function updateServerSaveButton() {
const hasChanges = serverSelect.value !== initialServerValue;
saveServerBtn.disabled = !hasChanges;
saveServerBtn.classList.toggle('btn-primary', hasChanges);
saveServerBtn.classList.toggle('btn-secondary', !hasChanges);
}
// Enable/disable import save button based on changes
function updateImportSaveButton() {
const vcPatternChanged = vcPatternInput.value !== initialVcPatternValue;
const useSysnameChanged = useSysnameCheckbox ? (useSysnameCheckbox.checked !== initialUseSysnameValue) : false;
const stripDomainChanged = stripDomainCheckbox ? (stripDomainCheckbox.checked !== initialStripDomainValue) : false;
const hasChanges = vcPatternChanged || useSysnameChanged || stripDomainChanged;
saveImportBtn.disabled = !hasChanges;
saveImportBtn.classList.toggle('btn-primary', hasChanges);
saveImportBtn.classList.toggle('btn-secondary', !hasChanges);
}
// Listen for changes on respective form fields
serverSelect.addEventListener('change', updateServerSaveButton);
vcPatternInput.addEventListener('input', updateImportSaveButton);
if (useSysnameCheckbox) useSysnameCheckbox.addEventListener('change', updateImportSaveButton);
if (stripDomainCheckbox) stripDomainCheckbox.addEventListener('change', updateImportSaveButton);
// Initialize button states
updateServerSaveButton();
updateImportSaveButton();
});
</script>
{% endblock %}

View File

@@ -0,0 +1,51 @@
{% extends 'base/layout.html' %}
{% load buttons %}
{% load helpers %}
{% load plugins %}
{% load static %}
{% load i18n %}
{% load render_table from django_tables2 %}
{% block header %}
<div class="container-fluid mt-2 d-print-none">
<div>
<h1 class="page-title mt-1 mb-2">Site and Location Sync</h1>
<p>This page displays the synchronization status between NetBox sites and LibreNMS locations. It allows you to update or create locations in LibreNMS based on NetBox data.</p>
<p class="text-muted">Note: Only LibreNMS locations that match a Netbox site are shown on this page.</p>
</div>
<div class="mt-3">
<form method="get">
<div class="d-flex">
<div class="search-box" style="width: 300px">
{% for field in filter_form %}
{{ field }}
{% endfor %}
</div>
<div class="buttons m-2">
<button type="submit" class="btn btn-primary">Search</button>
<a href="." class="btn btn-secondary">Clear</a>
</div>
</div>
</form>
</div>
</div>
{% endblock %}
{% block content %}
<div class="container-fluid">
<form method="post">
{% csrf_token %}
<div class="row mb-3">
<div class="col-12">
<div class="card">
{% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
{% render_table table 'inc/table.html' %}
{% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
</div>
</div>
</div>
</form>
</div>
{% endblock content %}

View File

@@ -0,0 +1,15 @@
{% extends 'generic/object_list.html' %}
{% load helpers %}
{% block title %}LibreNMS Status Check{% endblock %}
{% block content %}
{% if table.page.paginator.count == 0 %}
<div class="text-muted mb-3">
Select filters to check device status in LibreNMS. Click the status to view device LibreNMS sync details.
</div>
{% endif %}
{{ block.super }}
{% endblock %}

View File

@@ -0,0 +1 @@
"""Unit test package for netbox_librenms_plugin."""

View File

@@ -0,0 +1,336 @@
"""Shared pytest fixtures for NetBox LibreNMS Plugin tests."""
from unittest.mock import MagicMock, patch
import pytest
# =============================================================================
# Configuration Fixtures
# =============================================================================
@pytest.fixture
def mock_multi_server_config():
"""Multi-server configuration dict."""
return {
"default": {
"librenms_url": "https://librenms-default.example.com",
"api_token": "default-token-12345",
"cache_timeout": 300,
"verify_ssl": True,
},
"secondary": {
"librenms_url": "https://librenms-secondary.example.com",
"api_token": "secondary-token-67890",
"cache_timeout": 600,
"verify_ssl": False,
},
}
@pytest.fixture
def mock_legacy_config():
"""Legacy single-server configuration dict (flat structure)."""
return {
"librenms_url": "https://librenms.example.com",
"api_token": "legacy-token-abcdef",
"cache_timeout": 300,
"verify_ssl": True,
}
# =============================================================================
# API Instance Fixtures
# =============================================================================
@pytest.fixture
def mock_librenms_api(mock_multi_server_config):
"""Pre-configured LibreNMSAPI instance with mocked dependencies."""
with patch("netbox_librenms_plugin.librenms_api.get_plugin_config") as mock_config:
mock_config.return_value = mock_multi_server_config
from netbox_librenms_plugin.librenms_api import LibreNMSAPI
api = LibreNMSAPI(server_key="default")
yield api
# =============================================================================
# NetBox Object Mocks (Avoid Database)
# =============================================================================
@pytest.fixture
def mock_netbox_device():
"""Mock NetBox Device object without database."""
device = MagicMock()
device.name = "test-device"
device.cf = {} # Custom fields
device.primary_ip4 = MagicMock()
device.primary_ip4.address = MagicMock()
device.primary_ip4.address.ip = "192.168.1.1"
device.primary_ip4.__str__ = lambda self: "192.168.1.1/24"
device.primary_ip6 = None
device._meta.model_name = "device"
return device
@pytest.fixture
def mock_netbox_vm():
"""Mock NetBox VirtualMachine object without database."""
vm = MagicMock()
vm.name = "test-vm"
vm.cf = {}
vm.primary_ip4 = MagicMock()
vm.primary_ip4.address = MagicMock()
vm.primary_ip4.address.ip = "10.0.0.1"
vm.primary_ip6 = None
vm._meta.model_name = "virtualmachine"
return vm
# =============================================================================
# HTTP Response Fixtures
# =============================================================================
@pytest.fixture
def mock_response_factory():
"""Factory for creating mock HTTP responses."""
def _create_response(status_code=200, json_data=None, raise_for_status=None):
response = MagicMock()
response.status_code = status_code
response.json.return_value = json_data or {}
response.ok = 200 <= status_code < 300
if raise_for_status:
response.raise_for_status.side_effect = raise_for_status
return response
return _create_response
@pytest.fixture
def mock_success_response(mock_response_factory):
"""Standard successful API response."""
return mock_response_factory(status_code=200, json_data={"status": "ok", "message": "Success"})
@pytest.fixture
def mock_device_response(mock_response_factory):
"""Mock response for device info endpoint."""
return mock_response_factory(
status_code=200,
json_data={
"status": "ok",
"devices": [
{
"device_id": 42,
"hostname": "test-device.example.com",
"sysName": "test-device",
"ip": "192.168.1.1",
"status": 1,
"location": "Data Center 1",
}
],
},
)
@pytest.fixture
def mock_error_response(mock_response_factory):
"""Standard error API response."""
return mock_response_factory(
status_code=500,
json_data={"status": "error", "message": "Internal server error"},
)
@pytest.fixture
def mock_auth_error_response(mock_response_factory):
"""Authentication error response (401)."""
return mock_response_factory(status_code=401, json_data={"status": "error", "message": "Unauthorized"})
# =============================================================================
# Phase 2: Import Utilities Fixtures
# =============================================================================
@pytest.fixture
def sample_librenms_device():
"""Sample LibreNMS device data for import tests."""
return {
"device_id": 1,
"hostname": "switch-01.example.com",
"sysName": "switch-01",
"ip": "192.168.1.1",
"location": "DC1",
"os": "ios",
"hardware": "C9300-48P",
"version": "17.3.1",
"status": 1,
}
@pytest.fixture
def sample_librenms_device_minimal():
"""Minimal LibreNMS device data with missing fields."""
return {
"device_id": 2,
"hostname": "10.0.0.1",
"status": 1,
}
@pytest.fixture
def sample_validation_state():
"""Sample validation state for testing updates."""
return {
"device_id": 1,
"hostname": "switch-01",
"is_ready": False,
"can_import": False,
"import_as_vm": False,
"existing_device": None,
"issues": ["Device role must be manually selected before import"],
"warnings": [],
"site": {
"found": True,
"site": MagicMock(id=1, name="DC1"),
"match_type": "exact",
},
"device_type": {
"found": True,
"device_type": MagicMock(id=1, model="C9300-48P"),
"match_type": "exact",
},
"device_role": {"found": False, "role": None, "available_roles": []},
"cluster": {"found": False, "cluster": None, "available_clusters": []},
"platform": {
"found": True,
"platform": MagicMock(id=1, name="ios"),
"match_type": "exact",
},
}
@pytest.fixture
def sample_validation_state_vm():
"""Sample validation state for VM import testing."""
return {
"device_id": 1,
"hostname": "vm-01",
"is_ready": False,
"can_import": False,
"import_as_vm": True,
"existing_device": None,
"issues": ["Cluster must be manually selected before import"],
"warnings": [],
"cluster": {"found": False, "cluster": None, "available_clusters": []},
"device_role": {"found": False, "role": None, "available_roles": []},
}
@pytest.fixture
def mock_netbox_site():
"""Mock NetBox Site object."""
site = MagicMock()
site.id = 1
site.name = "DC1"
site.slug = "dc1"
return site
@pytest.fixture
def mock_netbox_platform():
"""Mock NetBox Platform object."""
platform = MagicMock()
platform.id = 1
platform.name = "Cisco IOS"
platform.slug = "cisco_ios"
return platform
@pytest.fixture
def mock_netbox_device_type():
"""Mock NetBox DeviceType object."""
dt = MagicMock()
dt.id = 1
dt.model = "C9300-48P"
dt.manufacturer = MagicMock(name="Cisco")
return dt
@pytest.fixture
def mock_netbox_device_role():
"""Mock NetBox DeviceRole object."""
role = MagicMock()
role.id = 1
role.name = "Access Switch"
role.slug = "access-switch"
return role
@pytest.fixture
def mock_netbox_cluster():
"""Mock NetBox Cluster object."""
cluster = MagicMock()
cluster.id = 1
cluster.name = "VMware Cluster 1"
return cluster
@pytest.fixture
def mock_netbox_rack():
"""Mock NetBox Rack object."""
rack = MagicMock()
rack.id = 1
rack.name = "Rack A1"
rack.site = MagicMock(id=1, name="DC1")
return rack
# =============================================================================
# Server Mapping Fixtures (used by test_sync_view_mismatch.py)
# =============================================================================
@pytest.fixture
def mock_plugins_config_single_server():
"""PLUGINS_CONFIG with a single 'production' server (for _build_all_server_mappings tests)."""
return {
"netbox_librenms_plugin": {
"servers": {
"production": {
"display_name": "Production LibreNMS",
"librenms_url": "https://librenms.example.com",
},
}
}
}
@pytest.fixture
def mock_plugins_config_empty_servers():
"""PLUGINS_CONFIG with no configured servers (simulates all orphaned)."""
return {"netbox_librenms_plugin": {"servers": {}}}
@pytest.fixture
def mock_plugins_config_multi_server_mapping():
"""PLUGINS_CONFIG with 'production' and 'mock-dev' servers (for multi-server mapping tests)."""
return {
"netbox_librenms_plugin": {
"servers": {
"production": {
"display_name": "Production LibreNMS",
"librenms_url": "https://librenms.example.com",
},
"mock-dev": {
"display_name": "Mock",
"librenms_url": "http://mock.example.com",
},
}
}
}

View File

@@ -0,0 +1,262 @@
"""
Minimal HTTP mock for LibreNMS API responses.
Usage in tests (add to conftest.py or inline):
from netbox_librenms_plugin.tests.mock_librenms_server import librenms_mock_server
@pytest.fixture
def librenms_server():
with librenms_mock_server() as server:
yield server
"""
import json
import threading
from contextlib import contextmanager
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import parse_qs, urlparse
class _LibreNMSHandler(BaseHTTPRequestHandler):
"""Request handler that dispatches to registered route responses."""
def log_message(self, format, *args): # noqa: A002
pass # Suppress request logs in tests
def _send_json(self, status, body):
data = json.dumps(body).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def _handle_request(self, method, body=None):
"""Dispatch to the registered route for this path, with optional method+query fallback."""
parsed = urlparse(self.path)
path = parsed.path
query = parsed.query
routes = self.server.routes # type: ignore[attr-defined]
# Build lookup keys: prefer method+path+query, then path+query, then path-only.
candidates = []
if query:
candidates.append(f"{method} {path}?{query}")
candidates.append(f"{path}?{query}")
candidates.append(f"{method} {path}")
candidates.append(path)
for key in candidates:
if key in routes:
entry = routes[key]
if callable(entry):
status, resp_body = entry(
method=method,
path=path,
query=parse_qs(query),
headers=dict(self.headers),
body=body,
)
else:
status, resp_body = entry
self._send_json(status, resp_body)
return
self._send_json(404, {"status": "error", "message": f"No mock for {self.path}"})
def do_GET(self):
self._handle_request("GET")
def do_POST(self):
length = int(self.headers.get("Content-Length", 0))
raw_body = self.rfile.read(length) if length else b""
try:
body = json.loads(raw_body) if raw_body else None
except json.JSONDecodeError:
body = raw_body.decode(errors="replace")
self._handle_request("POST", body=body)
class MockLibreNMSServer:
"""
Context-manager wrapper around a simple HTTP mock server.
Attributes:
url (str): Base URL for the mock server (e.g. "http://127.0.0.1:PORT").
routes (dict): Mapping of URL path → (status_code, body_dict) or callable.
Callable routes receive keyword arguments: method, path, query, headers, body
and must return (status_code, body_dict).
Routes can also be keyed as "METHOD /path" for method-specific matching,
or "/path?query" for query-specific matching.
"""
def __init__(self):
self._server = HTTPServer(("127.0.0.1", 0), _LibreNMSHandler)
self._server.routes = {}
self.routes = self._server.routes # expose on wrapper as documented
self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
_, port = self._server.server_address
self.url = f"http://127.0.0.1:{port}"
def register(self, path: str, body, status: int = 200, method: str | None = None):
"""
Register a mock response for a URL path.
If *method* is given the route is stored as ``"METHOD /path"`` and only
matches requests using that HTTP verb. Omit *method* (or pass ``None``)
to match any verb on that path.
*body* may be a ``dict`` (serialised to JSON) or a callable. When a
callable is provided it is stored directly and invoked by the handler on
each matching request; the *status* argument is ignored in that case.
"""
key = f"{method} {path}" if method else path
if callable(body):
self._server.routes[key] = body
else:
self._server.routes[key] = (status, body)
def start(self):
self._thread.start()
return self
def stop(self):
self._server.shutdown()
self._server.server_close()
self._thread.join(timeout=5)
if self._thread.is_alive():
import warnings
warnings.warn(
f"MockLibreNMSServer thread {self._thread.ident} did not exit within 5 s; "
"socket may not be fully released",
ResourceWarning,
stacklevel=2,
)
# ------- default LibreNMS-shaped responses -------
def add_device_response(self, device_id: int = 1, hostname: str = "test-host"):
self.register(
"/api/v0/devices",
{"status": "ok", "id": device_id, "hostname": hostname},
method="POST",
)
def device_info_response(
self,
device_id: int = 1,
hostname: str = "test-host",
hardware: str = "WS-C3560X-24T-S",
os: str = "ios",
serial: str = "SN123",
ip: str = "192.168.1.1",
version: str = "15.2(4)E7",
features: str = "-",
location: str = "-",
):
self.register(
f"/api/v0/devices/{device_id}",
{
"status": "ok",
"devices": [
{
"device_id": device_id,
"hostname": hostname,
"hardware": hardware,
"os": os,
"serial": serial,
"sysName": hostname,
"ip": ip,
"version": version,
"features": features,
"location": location,
}
],
},
)
def ports_response(self, device_id: int = 1, ports=None):
if ports is None:
ports = [
{
"port_id": 101,
"ifName": "GigabitEthernet0/1",
"ifDescr": "GigabitEthernet0/1",
"ifType": "ethernetCsmacd",
"ifSpeed": 1_000_000_000,
"ifAdminStatus": "up",
"ifAlias": "uplink",
"ifPhysAddress": "aa:bb:cc:dd:ee:01",
"ifMtu": 1500,
"ifVlan": 1,
"ifTrunk": 0,
}
]
self.register(f"/api/v0/devices/{device_id}/ports", {"status": "ok", "ports": ports})
def auth_error_response(self, path="/api/v0/devices"):
self.register(path, {"status": "error", "message": "Authentication failed"}, status=401)
def inventory_response(self, device_id: int, items: list, status: int = 200):
"""Register a plain inventory response for /api/v0/inventory/{device_id}/all."""
payload_status = "ok" if 200 <= status < 300 else "error"
payload = (
{"status": payload_status, "inventory": items} if payload_status == "ok" else {"status": payload_status}
)
self.register(
f"/api/v0/inventory/{device_id}/all",
payload,
status=status,
method="GET",
)
def vc_inventory_callable(self, device_id: int, root_items: list, children_by_parent_index: dict):
"""
Register a callable route for VC detection two-call pattern.
detect_virtual_chassis_from_inventory() calls get_inventory_filtered() twice:
1. entPhysicalContainedIn=0 → root items
2. entPhysicalClass=chassis&entPhysicalContainedIn=<parent_index> → member chassis items
children_by_parent_index: dict mapping parent index (int) → list of chassis items
"""
root = root_items
children = children_by_parent_index
def _handler(method, path, query, headers, body):
contained_in = query.get("entPhysicalContainedIn", [None])[0]
if contained_in == "0":
return 200, {"status": "ok", "inventory": root}
if contained_in is not None:
# Require entPhysicalClass=chassis for child queries so tests catch
# any regression where the production code stops sending the class filter.
phy_class = query.get("entPhysicalClass", [None])[0]
if phy_class != "chassis":
return 200, {"status": "ok", "inventory": []}
try:
idx = int(contained_in)
except (TypeError, ValueError):
return 404, {"status": "error", "message": "bad contained_in"}
items = children.get(idx, [])
return 200, {"status": "ok", "inventory": items}
# No filter → return all (fallback for /all)
all_items = list(root)
for v in children.values():
all_items.extend(v)
return 200, {"status": "ok", "inventory": all_items}
self.register(f"/api/v0/inventory/{device_id}", _handler, method="GET")
self.register(f"/api/v0/inventory/{device_id}/all", _handler, method="GET")
@contextmanager
def librenms_mock_server():
"""Context manager that starts and stops a MockLibreNMSServer."""
server = MockLibreNMSServer()
server.start()
try:
yield server
finally:
server.stop()

View File

@@ -0,0 +1,967 @@
"""
Tests for background job implementation.
Tests the FilterDevicesJob, ImportDevicesJob, should_use_background_job logic,
job result loading, and graceful fallback behavior.
Refactored to use pure pytest without Django database dependencies.
All tests use mocking and direct attribute manipulation instead of HTTP requests.
"""
from unittest.mock import MagicMock, patch
class TestShouldUseBackgroundJob:
"""Test background job decision logic."""
def test_checkbox_checked_returns_true(self):
"""When use_background_job form field is True, return True for superusers."""
from netbox_librenms_plugin.views.imports.list import LibreNMSImportView
view = LibreNMSImportView()
view._filter_form_data = {"use_background_job": True}
view.request = MagicMock()
view.request.user.is_superuser = True
assert view.should_use_background_job() is True
def test_checkbox_unchecked_returns_false(self):
"""When use_background_job form field is False, return False."""
from netbox_librenms_plugin.views.imports.list import LibreNMSImportView
view = LibreNMSImportView()
view._filter_form_data = {"use_background_job": False}
view.request = MagicMock()
view.request.user.is_superuser = True
assert view.should_use_background_job() is False
def test_default_when_field_missing(self):
"""When field is missing, default to True for superusers."""
from netbox_librenms_plugin.views.imports.list import LibreNMSImportView
view = LibreNMSImportView()
view._filter_form_data = {"some_other_field": "value"}
view.request = MagicMock()
view.request.user.is_superuser = True
assert view.should_use_background_job() is True
def test_empty_form_data_returns_default(self):
"""Empty form data returns default True for superusers."""
from netbox_librenms_plugin.views.imports.list import LibreNMSImportView
view = LibreNMSImportView()
view._filter_form_data = {}
view.request = MagicMock()
view.request.user.is_superuser = True
assert view.should_use_background_job() is True
def test_non_superuser_always_returns_false(self):
"""Non-superuser users always get synchronous mode."""
from netbox_librenms_plugin.views.imports.list import LibreNMSImportView
view = LibreNMSImportView()
view._filter_form_data = {"use_background_job": True}
view.request = MagicMock()
view.request.user.is_superuser = False
# Even when checkbox is True, non-superusers get False
assert view.should_use_background_job() is False
def create_mock_job_runner(job_class, job_pk=123):
"""Create a mock job runner instance without invoking real __init__."""
# Create instance without calling __init__
job = object.__new__(job_class)
# Set up required attributes
job.job = MagicMock()
job.job.pk = job_pk
job.job.data = {}
job.logger = MagicMock()
return job
class TestFilterDevicesJob:
"""Test FilterDevicesJob background job."""
@patch("netbox_librenms_plugin.librenms_api.LibreNMSAPI")
@patch("netbox_librenms_plugin.import_utils.process_device_filters")
def test_run_processes_filters_successfully(self, mock_process, mock_api_class):
"""Job runs and processes filters correctly."""
from netbox_librenms_plugin.jobs import FilterDevicesJob
# Setup mocks
mock_api = MagicMock()
mock_api.cache_timeout = 300
mock_api.server_key = "default"
mock_api_class.return_value = mock_api
validated_devices = [
{"device_id": 1, "hostname": "test1", "_validation": {}},
{"device_id": 2, "hostname": "test2", "_validation": {}},
]
mock_process.return_value = validated_devices
# Create job instance without calling real __init__
job = create_mock_job_runner(FilterDevicesJob)
# Run job
filters = {"location": "site1"}
job.run(
filters=filters,
vc_detection_enabled=True,
clear_cache=False,
show_disabled=False,
)
# Verify process_device_filters was called with correct args
mock_process.assert_called_once()
call_kwargs = mock_process.call_args.kwargs
assert call_kwargs["filters"] == filters
assert call_kwargs["vc_detection_enabled"] is True
assert call_kwargs["clear_cache"] is False
assert call_kwargs["job"] == job
@patch("netbox_librenms_plugin.librenms_api.LibreNMSAPI")
@patch("netbox_librenms_plugin.import_utils.process_device_filters")
def test_run_with_vc_detection_enabled(self, mock_process, mock_api_class):
"""vc_detection_enabled=True passed to processor."""
from netbox_librenms_plugin.jobs import FilterDevicesJob
mock_api = MagicMock()
mock_api.cache_timeout = 300
mock_api_class.return_value = mock_api
mock_process.return_value = []
job = create_mock_job_runner(FilterDevicesJob)
job.run(
filters={},
vc_detection_enabled=True,
clear_cache=False,
show_disabled=False,
)
call_kwargs = mock_process.call_args.kwargs
assert call_kwargs["vc_detection_enabled"] is True
@patch("netbox_librenms_plugin.librenms_api.LibreNMSAPI")
@patch("netbox_librenms_plugin.import_utils.process_device_filters")
def test_run_with_clear_cache(self, mock_process, mock_api_class):
"""clear_cache=True triggers cache refresh."""
from netbox_librenms_plugin.jobs import FilterDevicesJob
mock_api = MagicMock()
mock_api.cache_timeout = 300
mock_api_class.return_value = mock_api
mock_process.return_value = []
job = create_mock_job_runner(FilterDevicesJob)
job.run(
filters={},
vc_detection_enabled=False,
clear_cache=True,
show_disabled=False,
)
call_kwargs = mock_process.call_args.kwargs
assert call_kwargs["clear_cache"] is True
@patch("netbox_librenms_plugin.librenms_api.LibreNMSAPI")
@patch("netbox_librenms_plugin.import_utils.process_device_filters")
def test_run_with_show_disabled(self, mock_process, mock_api_class):
"""show_disabled=True includes disabled devices."""
from netbox_librenms_plugin.jobs import FilterDevicesJob
mock_api = MagicMock()
mock_api.cache_timeout = 300
mock_api_class.return_value = mock_api
mock_process.return_value = []
job = create_mock_job_runner(FilterDevicesJob)
job.run(
filters={},
vc_detection_enabled=False,
clear_cache=False,
show_disabled=True,
)
call_kwargs = mock_process.call_args.kwargs
assert call_kwargs["show_disabled"] is True
@patch("netbox_librenms_plugin.librenms_api.LibreNMSAPI")
@patch("netbox_librenms_plugin.import_utils.process_device_filters")
def test_run_with_exclude_existing(self, mock_process, mock_api_class):
"""exclude_existing=True filters out NetBox devices."""
from netbox_librenms_plugin.jobs import FilterDevicesJob
mock_api = MagicMock()
mock_api.cache_timeout = 300
mock_api_class.return_value = mock_api
mock_process.return_value = []
job = create_mock_job_runner(FilterDevicesJob)
job.run(
filters={},
vc_detection_enabled=False,
clear_cache=False,
show_disabled=False,
exclude_existing=True,
)
call_kwargs = mock_process.call_args.kwargs
assert call_kwargs["exclude_existing"] is True
@patch("netbox_librenms_plugin.librenms_api.LibreNMSAPI")
@patch("netbox_librenms_plugin.import_utils.process_device_filters")
def test_run_with_custom_server_key(self, mock_process, mock_api_class):
"""Non-default server_key used for API."""
from netbox_librenms_plugin.jobs import FilterDevicesJob
mock_api = MagicMock()
mock_api.cache_timeout = 300
mock_api.server_key = "secondary"
mock_api_class.return_value = mock_api
mock_process.return_value = [{"device_id": 1, "hostname": "test1"}]
job = create_mock_job_runner(FilterDevicesJob)
job.run(
filters={},
vc_detection_enabled=False,
clear_cache=False,
show_disabled=False,
server_key="secondary",
)
# Verify API was initialized with correct server_key
mock_api_class.assert_called_once_with(server_key="secondary")
# Verify server_key stored in job data
assert job.job.data["server_key"] == "secondary"
@patch("netbox_librenms_plugin.librenms_api.LibreNMSAPI")
@patch("netbox_librenms_plugin.import_utils.process_device_filters")
def test_run_stores_job_data_correctly(self, mock_process, mock_api_class):
"""Job stores expected data structure."""
from netbox_librenms_plugin.jobs import FilterDevicesJob
mock_api = MagicMock()
mock_api.cache_timeout = 300
mock_api.server_key = "secondary"
mock_api_class.return_value = mock_api
mock_process.return_value = [
{"device_id": 1, "hostname": "test1"},
{"device_id": 2, "hostname": "test2"},
]
job = create_mock_job_runner(FilterDevicesJob, job_pk=456)
job.run(
filters={"location": "dc1"},
vc_detection_enabled=True,
clear_cache=False,
show_disabled=False,
server_key="secondary",
)
# Verify job.data structure
assert job.job.data["device_ids"] == [1, 2]
assert job.job.data["total_processed"] == 2
assert job.job.data["filters"] == {"location": "dc1"}
assert job.job.data["server_key"] == "secondary"
assert job.job.data["vc_detection_enabled"] is True
assert job.job.data["cache_timeout"] == 300
assert "cached_at" in job.job.data
assert job.job.data["completed"] is True
job.job.save.assert_called()
@patch("netbox_librenms_plugin.librenms_api.LibreNMSAPI")
@patch("netbox_librenms_plugin.import_utils.process_device_filters")
def test_run_handles_empty_results(self, mock_process, mock_api_class):
"""Empty filter results handled gracefully."""
from netbox_librenms_plugin.jobs import FilterDevicesJob
mock_api = MagicMock()
mock_api.cache_timeout = 300
mock_api_class.return_value = mock_api
mock_process.return_value = []
job = create_mock_job_runner(FilterDevicesJob, job_pk=789)
job.run(
filters={"location": "nonexistent"},
vc_detection_enabled=False,
clear_cache=False,
show_disabled=False,
)
# Verify job data shows zero devices
assert job.job.data["device_ids"] == []
assert job.job.data["total_processed"] == 0
assert job.job.data["completed"] is True
@patch("netbox_librenms_plugin.librenms_api.LibreNMSAPI")
@patch("netbox_librenms_plugin.import_utils.process_device_filters")
def test_run_logs_progress(self, mock_process, mock_api_class):
"""Logger called with expected messages."""
from netbox_librenms_plugin.jobs import FilterDevicesJob
mock_api = MagicMock()
mock_api.cache_timeout = 300
mock_api_class.return_value = mock_api
mock_process.return_value = [{"device_id": 1, "hostname": "test1"}]
job = create_mock_job_runner(FilterDevicesJob)
job.run(
filters={"location": "site1"},
vc_detection_enabled=True,
clear_cache=False,
show_disabled=False,
)
# Verify logger was called with expected messages
assert job.logger.info.call_count >= 3
info_calls = [call[0][0] for call in job.logger.info.call_args_list]
assert any("Starting" in msg for msg in info_calls)
assert any("completed" in msg.lower() for msg in info_calls)
def test_job_meta_name(self):
"""Job has correct Meta.name."""
from netbox_librenms_plugin.jobs import FilterDevicesJob
assert FilterDevicesJob.Meta.name == "LibreNMS Device Filter"
class TestImportDevicesJob:
"""Test ImportDevicesJob background job."""
@patch("netbox_librenms_plugin.import_utils.bulk_import_vms")
@patch("netbox_librenms_plugin.import_utils.bulk_import_devices_shared")
@patch("netbox_librenms_plugin.librenms_api.LibreNMSAPI")
def test_run_device_only_import(self, mock_api_class, mock_bulk_devices, mock_bulk_vms):
"""Import devices without VMs."""
from netbox_librenms_plugin.jobs import ImportDevicesJob
mock_api_class.return_value = MagicMock()
# Mock successful device imports
mock_device_1 = MagicMock()
mock_device_1.pk = 100
mock_device_2 = MagicMock()
mock_device_2.pk = 101
mock_bulk_devices.return_value = {
"success": [
{"device": mock_device_1, "device_id": 1},
{"device": mock_device_2, "device_id": 2},
],
"failed": [],
"skipped": [],
"virtual_chassis_created": 0,
}
job = create_mock_job_runner(ImportDevicesJob, job_pk=789)
job.run(
device_ids=[1, 2],
vm_imports={},
server_key="default",
sync_options={"sync_interfaces": True},
)
# Verify device import was called
mock_bulk_devices.assert_called_once()
# VM import should not be called with empty dict
mock_bulk_vms.assert_not_called()
# Verify job.data
assert job.job.data["imported_device_pks"] == [100, 101]
assert job.job.data["imported_vm_pks"] == []
assert job.job.data["success_count"] == 2
assert job.job.data["failed_count"] == 0
@patch("netbox_librenms_plugin.import_utils.bulk_import_vms")
@patch("netbox_librenms_plugin.import_utils.bulk_import_devices_shared")
@patch("netbox_librenms_plugin.librenms_api.LibreNMSAPI")
def test_run_vm_only_import(self, mock_api_class, mock_bulk_devices, mock_bulk_vms):
"""Import VMs without devices."""
from netbox_librenms_plugin.jobs import ImportDevicesJob
mock_api_class.return_value = MagicMock()
# Mock successful VM imports
mock_vm_1 = MagicMock()
mock_vm_1.pk = 200
mock_vm_2 = MagicMock()
mock_vm_2.pk = 201
mock_bulk_vms.return_value = {
"success": [
{"device": mock_vm_1, "device_id": 10},
{"device": mock_vm_2, "device_id": 11},
],
"failed": [],
"skipped": [],
}
job = create_mock_job_runner(ImportDevicesJob, job_pk=790)
job.run(
device_ids=[],
vm_imports={10: {"cluster_id": 1}, 11: {"cluster_id": 1}},
server_key="default",
)
# Verify device import was not called with empty list
mock_bulk_devices.assert_not_called()
# VM import should be called
mock_bulk_vms.assert_called_once()
# Verify job.data
assert job.job.data["imported_device_pks"] == []
assert job.job.data["imported_vm_pks"] == [200, 201]
assert job.job.data["success_count"] == 2
@patch("netbox_librenms_plugin.import_utils.bulk_import_vms")
@patch("netbox_librenms_plugin.import_utils.bulk_import_devices_shared")
@patch("netbox_librenms_plugin.librenms_api.LibreNMSAPI")
def test_run_mixed_device_and_vm_import(self, mock_api_class, mock_bulk_devices, mock_bulk_vms):
"""Import both devices and VMs."""
from netbox_librenms_plugin.jobs import ImportDevicesJob
mock_api = MagicMock()
mock_api.server_key = "non-default"
mock_api_class.return_value = mock_api
# Mock device imports
mock_device = MagicMock()
mock_device.pk = 100
mock_bulk_devices.return_value = {
"success": [{"device": mock_device, "device_id": 1}],
"failed": [],
"skipped": [],
"virtual_chassis_created": 0,
"cancelled": False,
}
# Mock VM imports
mock_vm = MagicMock()
mock_vm.pk = 200
mock_bulk_vms.return_value = {
"success": [{"device": mock_vm, "device_id": 10}],
"failed": [],
"skipped": [],
}
job = create_mock_job_runner(ImportDevicesJob, job_pk=791)
job.run(
device_ids=[1],
vm_imports={10: {"cluster_id": 1}},
server_key="non-default",
)
# Both should be called
mock_bulk_devices.assert_called_once()
mock_bulk_vms.assert_called_once()
# Verify server_key (via api.server_key) is forwarded to bulk_import_devices_shared
bulk_devices_kwargs = mock_bulk_devices.call_args[1]
assert bulk_devices_kwargs.get("server_key") == "non-default"
# Verify bulk_import_vms received the api with the correct server_key
bulk_vms_positional = mock_bulk_vms.call_args[0]
assert bulk_vms_positional[1].server_key == "non-default"
# Verify combined results
assert job.job.data["imported_device_pks"] == [100]
assert job.job.data["imported_vm_pks"] == [200]
assert job.job.data["success_count"] == 2
assert job.job.data["total"] == 2
@patch("netbox_librenms_plugin.import_utils.bulk_import_vms")
@patch("netbox_librenms_plugin.import_utils.bulk_import_devices_shared")
@patch("netbox_librenms_plugin.librenms_api.LibreNMSAPI")
def test_run_with_sync_options(self, mock_api_class, mock_bulk_devices, mock_bulk_vms):
"""Sync options passed to bulk import."""
from netbox_librenms_plugin.jobs import ImportDevicesJob
mock_api_class.return_value = MagicMock()
mock_bulk_devices.return_value = {
"success": [],
"failed": [],
"skipped": [],
"virtual_chassis_created": 0,
}
job = create_mock_job_runner(ImportDevicesJob, job_pk=792)
sync_options = {
"sync_interfaces": True,
"sync_cables": False,
"sync_ips": True,
"use_sysname": True,
"strip_domain": True,
}
job.run(
device_ids=[1],
vm_imports={},
server_key="default",
sync_options=sync_options,
)
# Verify sync_options passed to bulk_import_devices_shared
call_kwargs = mock_bulk_devices.call_args.kwargs
assert call_kwargs["sync_options"] == sync_options
@patch("netbox_librenms_plugin.import_utils.bulk_import_vms")
@patch("netbox_librenms_plugin.import_utils.bulk_import_devices_shared")
@patch("netbox_librenms_plugin.librenms_api.LibreNMSAPI")
def test_run_with_manual_mappings(self, mock_api_class, mock_bulk_devices, mock_bulk_vms):
"""Manual mappings passed correctly."""
from netbox_librenms_plugin.jobs import ImportDevicesJob
mock_api_class.return_value = MagicMock()
mock_bulk_devices.return_value = {
"success": [],
"failed": [],
"skipped": [],
"virtual_chassis_created": 0,
}
job = create_mock_job_runner(ImportDevicesJob, job_pk=793)
manual_mappings = {
1: {"site_id": 10, "device_role_id": 5},
2: {"site_id": 11, "device_role_id": 6},
}
job.run(
device_ids=[1, 2],
vm_imports={},
manual_mappings_per_device=manual_mappings,
)
# Verify manual_mappings passed to bulk_import_devices_shared
call_kwargs = mock_bulk_devices.call_args.kwargs
assert call_kwargs["manual_mappings_per_device"] == manual_mappings
@patch("netbox_librenms_plugin.import_utils.bulk_import_vms")
@patch("netbox_librenms_plugin.import_utils.bulk_import_devices_shared")
@patch("netbox_librenms_plugin.librenms_api.LibreNMSAPI")
def test_run_stores_imported_pks(self, mock_api_class, mock_bulk_devices, mock_bulk_vms):
"""Imported device/VM PKs stored in job.data."""
from netbox_librenms_plugin.jobs import ImportDevicesJob
mock_api_class.return_value = MagicMock()
mock_device = MagicMock()
mock_device.pk = 100
mock_bulk_devices.return_value = {
"success": [{"device": mock_device, "device_id": 1}],
"failed": [],
"skipped": [],
"virtual_chassis_created": 0,
}
job = create_mock_job_runner(ImportDevicesJob, job_pk=794)
job.run(device_ids=[1], vm_imports={})
assert 100 in job.job.data["imported_device_pks"]
@patch("netbox_librenms_plugin.import_utils.bulk_import_vms")
@patch("netbox_librenms_plugin.import_utils.bulk_import_devices_shared")
@patch("netbox_librenms_plugin.librenms_api.LibreNMSAPI")
def test_run_stores_libre_device_ids(self, mock_api_class, mock_bulk_devices, mock_bulk_vms):
"""LibreNMS device IDs stored for re-render."""
from netbox_librenms_plugin.jobs import ImportDevicesJob
mock_api_class.return_value = MagicMock()
mock_device = MagicMock()
mock_device.pk = 100
mock_bulk_devices.return_value = {
"success": [{"device": mock_device, "device_id": 42}],
"failed": [],
"skipped": [],
"virtual_chassis_created": 0,
}
job = create_mock_job_runner(ImportDevicesJob, job_pk=795)
job.run(device_ids=[42], vm_imports={})
assert 42 in job.job.data["imported_libre_device_ids"]
@patch("netbox_librenms_plugin.import_utils.bulk_import_vms")
@patch("netbox_librenms_plugin.import_utils.bulk_import_devices_shared")
@patch("netbox_librenms_plugin.librenms_api.LibreNMSAPI")
def test_run_aggregates_errors(self, mock_api_class, mock_bulk_devices, mock_bulk_vms):
"""Device and VM errors are combined in job.data."""
from netbox_librenms_plugin.jobs import ImportDevicesJob
mock_api_class.return_value = MagicMock()
# Mock mixed results
mock_bulk_devices.return_value = {
"success": [],
"failed": [{"device_id": 1, "error": "Device type not found"}],
"skipped": [],
"virtual_chassis_created": 0,
}
mock_bulk_vms.return_value = {
"success": [],
"failed": [{"device_id": 10, "error": "Cluster not specified"}],
"skipped": [],
}
job = create_mock_job_runner(ImportDevicesJob, job_pk=999)
job.run(
device_ids=[1],
vm_imports={10: {"cluster": None}},
)
# Verify errors aggregated
assert len(job.job.data["errors"]) == 2
assert job.job.data["failed_count"] == 2
assert job.job.data["success_count"] == 0
@patch("netbox_librenms_plugin.import_utils.bulk_import_vms")
@patch("netbox_librenms_plugin.import_utils.bulk_import_devices_shared")
@patch("netbox_librenms_plugin.librenms_api.LibreNMSAPI")
def test_run_handles_all_failures(self, mock_api_class, mock_bulk_devices, mock_bulk_vms):
"""All imports fail gracefully."""
from netbox_librenms_plugin.jobs import ImportDevicesJob
mock_api_class.return_value = MagicMock()
mock_bulk_devices.return_value = {
"success": [],
"failed": [
{"device_id": 1, "error": "Error 1"},
{"device_id": 2, "error": "Error 2"},
],
"skipped": [],
"virtual_chassis_created": 0,
}
job = create_mock_job_runner(ImportDevicesJob, job_pk=800)
job.run(device_ids=[1, 2], vm_imports={})
# Should complete without exception
assert job.job.data["success_count"] == 0
assert job.job.data["failed_count"] == 2
assert job.job.data["completed"] is True
job.job.save.assert_called()
def test_job_meta_name(self):
"""Job has correct Meta.name."""
from netbox_librenms_plugin.jobs import ImportDevicesJob
assert ImportDevicesJob.Meta.name == "LibreNMS Device Import"
class TestLoadJobResults:
"""Test loading results from completed background jobs."""
@patch("netbox_librenms_plugin.views.imports.list.cache")
@patch("netbox_librenms_plugin.import_utils.get_validated_device_cache_key")
@patch("core.models.Job")
def test_load_success_uses_correct_cache_keys(self, mock_job_class, mock_get_key, mock_cache):
"""Load uses get_validated_device_cache_key with job data."""
from netbox_librenms_plugin.views.imports.list import LibreNMSImportView
# Setup mock job
mock_job = MagicMock()
mock_job.status = "completed"
mock_job.data = {
"device_ids": [1, 2],
"filters": {"location": "dc1"},
"server_key": "primary",
"vc_detection_enabled": True,
"cached_at": "2026-01-20T10:00:00Z",
"cache_timeout": 600,
"use_sysname": True,
"strip_domain": False,
}
mock_job_class.objects.get.return_value = mock_job
# Mock cache key generation
mock_get_key.side_effect = lambda **kw: f"key_{kw['device_id']}"
# Mock cache returns
mock_cache.get.side_effect = [
{"device_id": 1, "hostname": "test1"},
{"device_id": 2, "hostname": "test2"},
]
view = LibreNMSImportView()
results = view._load_job_results(123)
# Verify cache key function called with correct params
assert mock_get_key.call_count == 2
mock_get_key.assert_any_call(
server_key="primary",
filters={"location": "dc1"},
device_id=1,
vc_enabled=True,
use_sysname=True,
strip_domain=False,
)
mock_get_key.assert_any_call(
server_key="primary",
filters={"location": "dc1"},
device_id=2,
vc_enabled=True,
use_sysname=True,
strip_domain=False,
)
assert len(results) == 2
@patch("netbox_librenms_plugin.views.imports.list.cache")
@patch("netbox_librenms_plugin.import_utils.get_validated_device_cache_key")
@patch("core.models.Job")
def test_load_extracts_filters_from_job_data(self, mock_job_class, mock_get_key, mock_cache):
"""Filters, server_key, vc_enabled extracted from job data."""
from netbox_librenms_plugin.views.imports.list import LibreNMSImportView
mock_job = MagicMock()
mock_job.status = "completed"
mock_job.data = {
"device_ids": [1],
"filters": {"location": "dc2", "type": "router"},
"server_key": "secondary",
"vc_detection_enabled": False,
"cached_at": "2026-01-20T10:00:00Z",
"cache_timeout": 300,
"use_sysname": True,
"strip_domain": False,
}
mock_job_class.objects.get.return_value = mock_job
mock_get_key.return_value = "test_key"
mock_cache.get.return_value = {"device_id": 1}
view = LibreNMSImportView()
view._load_job_results(456)
# Verify get_validated_device_cache_key called with extracted values
mock_get_key.assert_called_once_with(
server_key="secondary",
filters={"location": "dc2", "type": "router"},
device_id=1,
vc_enabled=False,
use_sysname=True,
strip_domain=False,
)
@patch("netbox_librenms_plugin.views.imports.list.cache")
@patch("netbox_librenms_plugin.import_utils.get_validated_device_cache_key")
@patch("core.models.Job")
def test_load_returns_cached_devices(self, mock_job_class, mock_get_key, mock_cache):
"""Devices retrieved from cache."""
from netbox_librenms_plugin.views.imports.list import LibreNMSImportView
mock_job = MagicMock()
mock_job.status = "completed"
mock_job.data = {
"device_ids": [1, 2],
"filters": {},
"server_key": "default",
"vc_detection_enabled": False,
"cached_at": "2026-01-20T10:00:00Z",
"cache_timeout": 300,
}
mock_job_class.objects.get.return_value = mock_job
mock_get_key.side_effect = lambda **kw: f"key_{kw['device_id']}"
mock_cache.get.side_effect = [
{"device_id": 1, "hostname": "device1"},
{"device_id": 2, "hostname": "device2"},
]
view = LibreNMSImportView()
results = view._load_job_results(789)
assert len(results) == 2
assert results[0]["hostname"] == "device1"
assert results[1]["hostname"] == "device2"
@patch("netbox_librenms_plugin.views.imports.list.cache")
@patch("netbox_librenms_plugin.import_utils.get_validated_device_cache_key")
@patch("core.models.Job")
def test_load_sets_cache_metadata(self, mock_job_class, mock_get_key, mock_cache):
"""Load sets _cache_timestamp and _cache_timeout on view."""
from netbox_librenms_plugin.views.imports.list import LibreNMSImportView
mock_job = MagicMock()
mock_job.status = "completed"
mock_job.data = {
"device_ids": [1],
"filters": {},
"server_key": "default",
"vc_detection_enabled": False,
"cached_at": "2026-01-20T12:00:00Z",
"cache_timeout": 900,
}
mock_job_class.objects.get.return_value = mock_job
mock_get_key.return_value = "test_key"
mock_cache.get.return_value = {"device_id": 1}
view = LibreNMSImportView()
view._load_job_results(456)
assert view._cache_timestamp == "2026-01-20T12:00:00Z"
assert view._cache_timeout == 900
@patch("core.models.Job")
def test_load_job_not_found_returns_empty(self, mock_job_class):
"""Non-existent job returns empty list."""
from netbox_librenms_plugin.views.imports.list import LibreNMSImportView
# Create a mock DoesNotExist exception
mock_job_class.DoesNotExist = Exception
mock_job_class.objects.get.side_effect = mock_job_class.DoesNotExist
view = LibreNMSImportView()
results = view._load_job_results(999)
assert results == []
@patch("core.models.Job")
def test_load_job_not_completed_returns_empty(self, mock_job_class):
"""Running job returns empty list."""
from netbox_librenms_plugin.views.imports.list import LibreNMSImportView
mock_job = MagicMock()
mock_job.status = "running"
mock_job_class.objects.get.return_value = mock_job
view = LibreNMSImportView()
results = view._load_job_results(123)
assert results == []
@patch("netbox_librenms_plugin.views.imports.list.cache")
@patch("netbox_librenms_plugin.import_utils.get_validated_device_cache_key")
@patch("core.models.Job")
def test_load_expired_cache_returns_empty(self, mock_job_class, mock_get_key, mock_cache):
"""All cache misses returns empty list."""
from netbox_librenms_plugin.views.imports.list import LibreNMSImportView
mock_job = MagicMock()
mock_job.status = "completed"
mock_job.data = {
"device_ids": [1, 2],
"filters": {},
"server_key": "default",
"vc_detection_enabled": False,
"cached_at": "2026-01-20T10:00:00Z",
"cache_timeout": 300,
}
mock_job_class.objects.get.return_value = mock_job
mock_get_key.side_effect = lambda **kw: f"key_{kw['device_id']}"
# Simulate expired cache (returns None)
mock_cache.get.return_value = None
view = LibreNMSImportView()
results = view._load_job_results(123)
assert results == []
@patch("netbox_librenms_plugin.views.imports.list.cache")
@patch("netbox_librenms_plugin.import_utils.get_validated_device_cache_key")
@patch("core.models.Job")
def test_load_partial_cache_returns_available(self, mock_job_class, mock_get_key, mock_cache):
"""Some expired, returns available devices."""
from netbox_librenms_plugin.views.imports.list import LibreNMSImportView
mock_job = MagicMock()
mock_job.status = "completed"
mock_job.data = {
"device_ids": [1, 2, 3],
"filters": {},
"server_key": "default",
"vc_detection_enabled": False,
"cached_at": "2026-01-20T10:00:00Z",
"cache_timeout": 300,
}
mock_job_class.objects.get.return_value = mock_job
mock_get_key.side_effect = lambda **kw: f"key_{kw['device_id']}"
# First device in cache, second expired, third in cache
mock_cache.get.side_effect = [
{"device_id": 1, "hostname": "device1"},
None, # Expired
{"device_id": 3, "hostname": "device3"},
]
view = LibreNMSImportView()
results = view._load_job_results(123)
# Should return available devices only
assert len(results) == 2
assert results[0]["device_id"] == 1
assert results[1]["device_id"] == 3
class TestGracefulFallback:
"""Test graceful fallback when RQ workers unavailable."""
@patch("netbox_librenms_plugin.views.imports.list.get_workers_for_queue")
def test_no_workers_triggers_synchronous_processing(self, mock_get_workers):
"""No RQ workers triggers synchronous fallback."""
mock_get_workers.return_value = 0
# This test verifies the condition check, not full request handling
from netbox_librenms_plugin.views.imports.list import get_workers_for_queue
workers = get_workers_for_queue("default")
assert workers == 0
# When workers == 0, the code path skips job enqueuing
# and falls through to synchronous get_queryset processing
@patch("netbox_librenms_plugin.views.imports.list.get_workers_for_queue")
def test_workers_available_allows_background_job(self, mock_get_workers):
"""Available workers allow background job enqueue."""
mock_get_workers.return_value = 2
from netbox_librenms_plugin.views.imports.list import get_workers_for_queue
workers = get_workers_for_queue("default")
assert workers > 0
# When workers > 0, the code path proceeds to FilterDevicesJob.enqueue()
@patch("netbox_librenms_plugin.views.imports.list.get_workers_for_queue")
@patch("netbox_librenms_plugin.views.imports.list.logger")
def test_fallback_logs_warning(self, mock_logger, mock_get_workers):
"""Warning logged when falling back (checked via worker count)."""
mock_get_workers.return_value = 0
# Verify the function returns 0 workers which would trigger fallback
from netbox_librenms_plugin.views.imports.list import get_workers_for_queue
workers = get_workers_for_queue("default")
assert workers == 0
# The view would log a warning when it detects no workers and falls back
# This test verifies the condition that triggers the fallback path

View File

@@ -0,0 +1,311 @@
"""
Regression tests for SingleCableVerifyView.post().
Covers:
- Stale derived fields are stripped before re-enrichment (prevents
DoesNotExist when remote objects are deleted after caching).
- LibreNMS-sourced labels are HTML-escaped to prevent XSS.
"""
import json
from unittest.mock import MagicMock, patch
def _make_view(server_key="default"):
"""Create a SingleCableVerifyView instance without database access."""
from netbox_librenms_plugin.views.base.cables_view import SingleCableVerifyView
view = object.__new__(SingleCableVerifyView)
view._librenms_api = MagicMock()
view._librenms_api.server_key = server_key
view.request = MagicMock()
return view
def _make_request(body_dict):
"""Create a mock POST request with JSON body."""
request = MagicMock()
request.method = "POST"
request.body = json.dumps(body_dict).encode()
request.META = {"HTTP_X_REQUESTED_WITH": "XMLHttpRequest"}
return request
class TestStaleFieldStripping:
"""Cached link data with stale derived fields must be stripped before use."""
def test_stale_remote_fields_stripped_before_enrichment(self):
"""Stale netbox_remote_device_id / remote_device_url must not reach check_cable_status()."""
view = _make_view()
# Cached link with stale derived fields (from a previous enrichment)
cached_link = {
"local_port": "eth0",
"local_port_id": 100,
"remote_port": "eth1",
"remote_device": "switch-remote",
"remote_port_id": 200,
"remote_device_id": 42,
# Stale derived fields — remote device was deleted after caching
"netbox_remote_device_id": 999,
"remote_device_url": "/dcim/devices/999/",
"netbox_remote_interface_id": 888,
"remote_port_url": "/dcim/interfaces/888/",
"cable_status": "No Cable",
"can_create_cable": True,
}
cached_data = {"links": [cached_link]}
device = MagicMock()
device.pk = 1
device.id = 1
device.virtual_chassis = None
interface_mock = MagicMock()
interface_mock.pk = 10
# Track what link_data check_cable_status receives
received_link_data = {}
def fake_check_cable_status(link):
received_link_data.update(link)
link["cable_status"] = "No Cable"
link["can_create_cable"] = True
return link
def fake_process_remote_device(link, hostname, device_id, server_key=None):
assert link is not None
assert hostname is not None
assert device_id is not None
assert server_key == "default"
# Simulate successful remote enrichment with fresh IDs
link["remote_device_url"] = "/dcim/devices/777/"
link["netbox_remote_device_id"] = 777
link["remote_port_url"] = "/dcim/interfaces/666/"
link["netbox_remote_interface_id"] = 666
link["remote_port_name"] = "eth1"
return link
request = _make_request({"device_id": 1, "local_port_id": 100})
with (
patch("netbox_librenms_plugin.views.base.cables_view.get_object_or_404", return_value=device),
patch("netbox_librenms_plugin.views.base.cables_view.cache") as mock_cache,
patch.object(view, "get_cache_key", return_value="test_key"),
patch.object(view, "check_cable_status", side_effect=fake_check_cable_status),
patch.object(view, "process_remote_device", side_effect=fake_process_remote_device),
patch("netbox_librenms_plugin.views.base.cables_view.get_librenms_sync_device", return_value=device),
patch("netbox_librenms_plugin.views.base.cables_view.get_virtual_chassis_member", return_value=device),
patch("netbox_librenms_plugin.views.base.cables_view._librenms_id_q", return_value=MagicMock()),
patch("netbox_librenms_plugin.views.base.cables_view.get_token", return_value="csrf123"),
patch("netbox_librenms_plugin.views.base.cables_view.reverse", return_value="/fake/"),
):
mock_cache.get.return_value = cached_data
# Make the interface filter return our mock
device.interfaces.filter.return_value.first.return_value = interface_mock
view.post(request)
# check_cable_status should have received fresh IDs from process_remote_device,
# NOT the stale 999/888 from cache
assert received_link_data.get("netbox_remote_device_id") == 777
assert received_link_data.get("netbox_remote_interface_id") == 666
def test_post_strips_derived_fields_from_cached_link(self):
"""post() must strip derived fields (URLs, IDs) before re-enrichment.
Both _prepare_context and post() define a _raw_keys set that controls
which cached fields survive into re-enrichment. This test verifies the
behavior: derived fields in the cached link must not leak through.
"""
view = _make_view()
# Cached link with both raw and derived (stale) fields
cached_link = {
"local_port": "eth0",
"local_port_id": 100,
"remote_port": "eth1",
"remote_device": "switch-a",
"remote_port_id": 200,
"remote_device_id": 42,
# Derived fields that must be stripped:
"netbox_local_interface_id": 999,
"netbox_remote_interface_id": 888,
"netbox_remote_device_id": 777,
"local_port_url": "/stale/",
"remote_port_url": "/stale/",
"remote_device_url": "/stale/",
"cable_status": "stale",
"can_create_cable": True,
}
# Mock process_remote_device to avoid DB access during re-enrichment;
# it should receive the link WITHOUT derived fields.
received_link = {}
def fake_process_remote(link, hostname, device_id, server_key=None):
received_link.update(link)
return link
view.process_remote_device = fake_process_remote
with (
patch("netbox_librenms_plugin.views.base.cables_view.get_object_or_404") as mock_get,
patch("netbox_librenms_plugin.views.base.cables_view.cache") as mock_cache,
patch("netbox_librenms_plugin.views.base.cables_view.get_librenms_sync_device", return_value=None),
patch("netbox_librenms_plugin.views.base.cables_view.get_token", return_value="tok"),
):
device = MagicMock()
device.pk = 1
device.virtual_chassis = None
device.interfaces.filter.return_value.first.return_value = None
mock_get.return_value = device
mock_cache.get.return_value = {"links": [cached_link]}
request = MagicMock()
request.body = json.dumps(
{
"device_id": 1,
"local_port_id": 100,
"server_key": "default",
}
)
view.post(request)
# The link passed to process_remote_device must have derived fields stripped
assert "netbox_local_interface_id" not in received_link
assert "netbox_remote_interface_id" not in received_link
assert "netbox_remote_device_id" not in received_link
assert "local_port_url" not in received_link
assert "cable_status" not in received_link
class TestXSSEscaping:
"""LibreNMS-sourced labels must be HTML-escaped in cable verify output."""
def test_xss_in_local_port_name_escaped(self):
"""A malicious local_port name must be escaped in the HTML output."""
view = _make_view()
xss_port_name = '<script>alert("xss")</script>'
cached_link = {
"local_port": xss_port_name,
"local_port_id": 100,
"remote_port": "eth1",
"remote_device": "safe-switch",
"remote_port_id": 200,
"remote_device_id": 42,
}
cached_data = {"links": [cached_link]}
device = MagicMock()
device.pk = 1
device.id = 1
device.virtual_chassis = None
interface_mock = MagicMock()
interface_mock.pk = 10
def fake_process_remote_device(link, hostname, device_id, server_key=None):
link["remote_device_url"] = "/dcim/devices/2/"
link["netbox_remote_device_id"] = 2
link["remote_port_url"] = "/dcim/interfaces/20/"
link["netbox_remote_interface_id"] = 20
link["remote_port_name"] = "eth1"
return link
def fake_check_cable_status(link):
link["cable_status"] = "No Cable"
link["can_create_cable"] = False
return link
request = _make_request({"device_id": 1, "local_port_id": 100})
with (
patch("netbox_librenms_plugin.views.base.cables_view.get_object_or_404", return_value=device),
patch("netbox_librenms_plugin.views.base.cables_view.cache") as mock_cache,
patch.object(view, "get_cache_key", return_value="test_key"),
patch.object(view, "check_cable_status", side_effect=fake_check_cable_status),
patch.object(view, "process_remote_device", side_effect=fake_process_remote_device),
patch("netbox_librenms_plugin.views.base.cables_view.get_librenms_sync_device", return_value=device),
patch("netbox_librenms_plugin.views.base.cables_view._librenms_id_q", return_value=MagicMock()),
patch("netbox_librenms_plugin.views.base.cables_view.get_token", return_value="csrf123"),
patch("netbox_librenms_plugin.views.base.cables_view.reverse", return_value="/fake/"),
):
mock_cache.get.return_value = cached_data
device.interfaces.filter.return_value.first.return_value = interface_mock
response = view.post(request)
content = json.loads(response.content)
row = content.get("formatted_row", {})
local_port_html = row.get("local_port", "")
# The raw script tag must NOT appear unescaped
assert "<script>" not in local_port_html
# The escaped version should be present
assert "&lt;script&gt;" in local_port_html
def test_xss_in_remote_device_name_escaped(self):
"""A malicious remote_device name must be escaped in the HTML output."""
view = _make_view()
xss_device = "<img src=x onerror=alert(1)>"
cached_link = {
"local_port": "eth0",
"local_port_id": 100,
"remote_port": "eth1",
"remote_device": xss_device,
"remote_port_id": 200,
"remote_device_id": 42,
}
cached_data = {"links": [cached_link]}
device = MagicMock()
device.pk = 1
device.id = 1
device.virtual_chassis = None
interface_mock = MagicMock()
interface_mock.pk = 10
def fake_process_remote_device(link, hostname, device_id, server_key=None):
# Remote device found — but name is the XSS payload
link["remote_device_url"] = "/dcim/devices/2/"
link["netbox_remote_device_id"] = 2
link["remote_port_url"] = "/dcim/interfaces/20/"
link["netbox_remote_interface_id"] = 20
link["remote_port_name"] = "eth1"
return link
def fake_check_cable_status(link):
link["cable_status"] = "No Cable"
link["can_create_cable"] = False
return link
request = _make_request({"device_id": 1, "local_port_id": 100})
with (
patch("netbox_librenms_plugin.views.base.cables_view.get_object_or_404", return_value=device),
patch("netbox_librenms_plugin.views.base.cables_view.cache") as mock_cache,
patch.object(view, "get_cache_key", return_value="test_key"),
patch.object(view, "check_cable_status", side_effect=fake_check_cable_status),
patch.object(view, "process_remote_device", side_effect=fake_process_remote_device),
patch("netbox_librenms_plugin.views.base.cables_view.get_librenms_sync_device", return_value=device),
patch("netbox_librenms_plugin.views.base.cables_view._librenms_id_q", return_value=MagicMock()),
patch("netbox_librenms_plugin.views.base.cables_view.get_token", return_value="csrf123"),
patch("netbox_librenms_plugin.views.base.cables_view.reverse", return_value="/fake/"),
):
mock_cache.get.return_value = cached_data
device.interfaces.filter.return_value.first.return_value = interface_mock
response = view.post(request)
content = json.loads(response.content)
row = content.get("formatted_row", {})
remote_device_html = row.get("remote_device", "")
# Raw HTML tag must not appear — angle brackets must be escaped
assert "<img " not in remote_device_html
# Escaped version should be present (browser renders as text, not tag)
assert "&lt;img" in remote_device_html

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,707 @@
"""
Coverage tests for:
- netbox_librenms_plugin/api/views.py (sync_job_status, InterfaceTypeMappingViewSet)
- netbox_librenms_plugin/filtersets.py
- netbox_librenms_plugin/models.py (lines 45, 48, 68, 76)
"""
import json
from unittest.mock import MagicMock, patch
# ===========================================================================
# Helpers
# ===========================================================================
def _make_post_request():
"""Return a minimal Django HttpRequest suitable for DRF view tests."""
from django.http import HttpRequest
request = HttpRequest()
request.method = "POST"
request.META["SERVER_NAME"] = "localhost"
request.META["SERVER_PORT"] = "80"
return request
def _call_sync_job_status(job_pk, job_patch, rq_patch=None, queue_patch=None):
"""
Call sync_job_status view, bypassing DRF auth/permission layer.
Returns the raw Django response object.
"""
from netbox_librenms_plugin.api.views import sync_job_status
request = _make_post_request()
# Bypass DRF initial() so we skip auth/permissions entirely
with patch("rest_framework.views.APIView.initial"):
with patch("netbox_librenms_plugin.api.views.Job", job_patch):
if rq_patch is not None and queue_patch is not None:
with patch("netbox_librenms_plugin.api.views.RQJob", rq_patch):
with patch("netbox_librenms_plugin.api.views.get_queue", queue_patch):
return sync_job_status(request, job_pk=job_pk)
return sync_job_status(request, job_pk=job_pk)
# ===========================================================================
# api/views.py sync_job_status
# ===========================================================================
class TestSyncJobStatusJobNotFound:
"""Test sync_job_status when the DB job does not exist."""
def test_returns_404_when_job_missing(self):
class _DoesNotExist(Exception):
pass
mock_job_cls = MagicMock()
mock_job_cls.DoesNotExist = _DoesNotExist
mock_job_cls.objects.get.side_effect = _DoesNotExist
response = _call_sync_job_status(job_pk=999, job_patch=mock_job_cls)
assert response.status_code == 404
data = json.loads(response.content)
assert data["error"] == "Job not found"
class TestSyncJobStatusRQJobActive:
"""Test sync_job_status when RQ job is still active (no update needed)."""
def test_no_change_when_rq_job_running(self):
from core.choices import JobStatusChoices
mock_db_job = MagicMock()
mock_db_job.pk = 1
mock_db_job.status = JobStatusChoices.STATUS_RUNNING
mock_db_job.completed = None
class _DoesNotExist(Exception):
pass
mock_job_cls = MagicMock()
mock_job_cls.DoesNotExist = _DoesNotExist
mock_job_cls.objects.get.return_value = mock_db_job
mock_rq_job = MagicMock()
mock_rq_job.is_stopped = False
mock_rq_job.is_failed = False
mock_rq_job.get_status.return_value = "started"
mock_rq_cls = MagicMock()
mock_rq_cls.fetch.return_value = mock_rq_job
mock_queue = MagicMock()
mock_queue_fn = MagicMock(return_value=mock_queue)
response = _call_sync_job_status(
job_pk=1,
job_patch=mock_job_cls,
rq_patch=mock_rq_cls,
queue_patch=mock_queue_fn,
)
assert response.status_code == 200
data = json.loads(response.content)
assert data["status"] == "no_change"
assert data["rq_status"] == "started"
mock_db_job.save.assert_not_called()
class TestSyncJobStatusRQJobStopped:
"""Test sync_job_status when RQ job is stopped/failed."""
def test_updates_db_when_rq_stopped_and_not_yet_completed(self):
from core.choices import JobStatusChoices
mock_db_job = MagicMock()
mock_db_job.pk = 2
mock_db_job.status = JobStatusChoices.STATUS_RUNNING
mock_db_job.completed = None
class _DoesNotExist(Exception):
pass
mock_job_cls = MagicMock()
mock_job_cls.DoesNotExist = _DoesNotExist
mock_job_cls.objects.get.return_value = mock_db_job
mock_rq_job = MagicMock()
mock_rq_job.is_stopped = True
mock_rq_job.is_failed = False
mock_rq_job.get_status.return_value = "stopped"
mock_rq_cls = MagicMock()
mock_rq_cls.fetch.return_value = mock_rq_job
mock_queue = MagicMock()
mock_queue_fn = MagicMock(return_value=mock_queue)
with patch("netbox_librenms_plugin.api.views.timezone") as mock_tz:
mock_tz.now.return_value = "2024-01-01"
response = _call_sync_job_status(
job_pk=2,
job_patch=mock_job_cls,
rq_patch=mock_rq_cls,
queue_patch=mock_queue_fn,
)
assert response.status_code == 200
data = json.loads(response.content)
assert data["status"] == "updated"
assert data["rq_status"] == "stopped"
mock_db_job.save.assert_called_once_with(update_fields=["status", "completed"])
assert mock_db_job.completed == "2024-01-01"
def test_updates_db_when_rq_failed(self):
from core.choices import JobStatusChoices
mock_db_job = MagicMock()
mock_db_job.pk = 3
mock_db_job.status = JobStatusChoices.STATUS_RUNNING
mock_db_job.completed = None
class _DoesNotExist(Exception):
pass
mock_job_cls = MagicMock()
mock_job_cls.DoesNotExist = _DoesNotExist
mock_job_cls.objects.get.return_value = mock_db_job
mock_rq_job = MagicMock()
mock_rq_job.is_stopped = False
mock_rq_job.is_failed = True
mock_rq_job.get_status.return_value = "failed"
mock_rq_cls = MagicMock()
mock_rq_cls.fetch.return_value = mock_rq_job
mock_queue = MagicMock()
mock_queue_fn = MagicMock(return_value=mock_queue)
with patch("netbox_librenms_plugin.api.views.timezone") as mock_tz:
mock_tz.now.return_value = "2024-01-02"
response = _call_sync_job_status(
job_pk=3,
job_patch=mock_job_cls,
rq_patch=mock_rq_cls,
queue_patch=mock_queue_fn,
)
assert response.status_code == 200
data = json.loads(response.content)
assert data["status"] == "updated"
assert data["rq_status"] == "failed"
assert mock_db_job.completed == "2024-01-02"
mock_db_job.save.assert_called_once_with(update_fields=["status", "completed"])
def test_does_not_overwrite_existing_completed_timestamp(self):
from core.choices import JobStatusChoices
mock_db_job = MagicMock()
mock_db_job.pk = 4
mock_db_job.status = JobStatusChoices.STATUS_RUNNING
mock_db_job.completed = "2024-01-01T10:00:00" # already set
class _DoesNotExist(Exception):
pass
mock_job_cls = MagicMock()
mock_job_cls.DoesNotExist = _DoesNotExist
mock_job_cls.objects.get.return_value = mock_db_job
mock_rq_job = MagicMock()
mock_rq_job.is_stopped = True
mock_rq_job.is_failed = False
mock_rq_job.get_status.return_value = "stopped"
mock_rq_cls = MagicMock()
mock_rq_cls.fetch.return_value = mock_rq_job
mock_queue = MagicMock()
mock_queue_fn = MagicMock(return_value=mock_queue)
with patch("netbox_librenms_plugin.api.views.timezone") as mock_tz:
response = _call_sync_job_status(
job_pk=4,
job_patch=mock_job_cls,
rq_patch=mock_rq_cls,
queue_patch=mock_queue_fn,
)
# timezone.now() should NOT have been called since completed is already set
mock_tz.now.assert_not_called()
assert response.status_code == 200
from core.choices import JobStatusChoices
assert mock_db_job.status == JobStatusChoices.STATUS_FAILED
mock_db_job.save.assert_called_once_with(update_fields=["status", "completed"])
class TestSyncJobStatusRQJobNotInQueue:
"""Test sync_job_status when RQ.fetch raises NoSuchJobError."""
def test_updates_db_to_failed_when_running_and_not_in_rq(self):
from core.choices import JobStatusChoices
from rq.exceptions import NoSuchJobError
mock_db_job = MagicMock()
mock_db_job.pk = 5
mock_db_job.status = JobStatusChoices.STATUS_RUNNING
mock_db_job.completed = None
class _DoesNotExist(Exception):
pass
mock_job_cls = MagicMock()
mock_job_cls.DoesNotExist = _DoesNotExist
mock_job_cls.objects.get.return_value = mock_db_job
mock_rq_cls = MagicMock()
mock_rq_cls.fetch.side_effect = NoSuchJobError("not found in redis")
mock_queue = MagicMock()
mock_queue_fn = MagicMock(return_value=mock_queue)
with patch("netbox_librenms_plugin.api.views.timezone") as mock_tz:
mock_tz.now.return_value = "2024-01-03"
response = _call_sync_job_status(
job_pk=5,
job_patch=mock_job_cls,
rq_patch=mock_rq_cls,
queue_patch=mock_queue_fn,
)
assert response.status_code == 200
data = json.loads(response.content)
assert data["status"] == "updated"
assert data["rq_status"] == "not_found"
mock_db_job.save.assert_called_once_with(update_fields=["status", "completed"])
def test_no_change_when_not_running_and_not_in_rq(self):
from core.choices import JobStatusChoices
from rq.exceptions import NoSuchJobError
mock_db_job = MagicMock()
mock_db_job.pk = 6
mock_db_job.status = JobStatusChoices.STATUS_COMPLETED
mock_db_job.completed = "2024-01-01"
class _DoesNotExist(Exception):
pass
mock_job_cls = MagicMock()
mock_job_cls.DoesNotExist = _DoesNotExist
mock_job_cls.objects.get.return_value = mock_db_job
mock_rq_cls = MagicMock()
mock_rq_cls.fetch.side_effect = NoSuchJobError("not found in redis")
mock_queue = MagicMock()
mock_queue_fn = MagicMock(return_value=mock_queue)
response = _call_sync_job_status(
job_pk=6,
job_patch=mock_job_cls,
rq_patch=mock_rq_cls,
queue_patch=mock_queue_fn,
)
assert response.status_code == 200
data = json.loads(response.content)
assert data["status"] == "no_change"
assert data["rq_status"] == "not_found"
mock_db_job.save.assert_not_called()
def test_does_not_overwrite_completed_when_not_in_rq(self):
from core.choices import JobStatusChoices
from rq.exceptions import NoSuchJobError
mock_db_job = MagicMock()
mock_db_job.pk = 7
mock_db_job.status = JobStatusChoices.STATUS_RUNNING
mock_db_job.completed = "2024-01-01T08:00:00" # already set
class _DoesNotExist(Exception):
pass
mock_job_cls = MagicMock()
mock_job_cls.DoesNotExist = _DoesNotExist
mock_job_cls.objects.get.return_value = mock_db_job
mock_rq_cls = MagicMock()
mock_rq_cls.fetch.side_effect = NoSuchJobError("gone")
mock_queue = MagicMock()
mock_queue_fn = MagicMock(return_value=mock_queue)
with patch("netbox_librenms_plugin.api.views.timezone") as mock_tz:
response = _call_sync_job_status(
job_pk=7,
job_patch=mock_job_cls,
rq_patch=mock_rq_cls,
queue_patch=mock_queue_fn,
)
mock_tz.now.assert_not_called()
assert response.status_code == 200
# ===========================================================================
# api/views.py InterfaceTypeMappingViewSet (class attributes)
# ===========================================================================
class TestInterfaceTypeMappingViewSet:
"""Test that InterfaceTypeMappingViewSet has expected class-level attributes."""
def test_viewset_has_correct_permission_classes(self):
from netbox_librenms_plugin.api.views import InterfaceTypeMappingViewSet, LibreNMSPluginPermission
assert LibreNMSPluginPermission in InterfaceTypeMappingViewSet.permission_classes
def test_viewset_has_serializer_class(self):
from netbox_librenms_plugin.api.views import InterfaceTypeMappingViewSet
from netbox_librenms_plugin.api.serializers import InterfaceTypeMappingSerializer
assert InterfaceTypeMappingViewSet.serializer_class is InterfaceTypeMappingSerializer
# ===========================================================================
# filtersets.py SiteLocationFilterSet
# ===========================================================================
class TestSiteLocationFilterSet:
"""Tests for SiteLocationFilterSet."""
def test_qs_returns_full_queryset_when_no_q(self):
from netbox_librenms_plugin.filtersets import SiteLocationFilterSet
mock_item = MagicMock()
queryset = [mock_item]
fs = SiteLocationFilterSet(data={}, queryset=queryset)
assert fs.qs == queryset
def test_qs_filters_when_q_provided(self):
from netbox_librenms_plugin.filtersets import SiteLocationFilterSet
matching_item = MagicMock()
matching_item.netbox_site.name = "Amsterdam"
matching_item.netbox_site.latitude = "52.37"
matching_item.netbox_site.longitude = "4.89"
matching_item.librenms_location = "AMS-DC1"
non_matching_item = MagicMock()
non_matching_item.netbox_site.name = "London"
non_matching_item.netbox_site.latitude = "51.5"
non_matching_item.netbox_site.longitude = "-0.12"
non_matching_item.librenms_location = "LON-DC1"
fs = SiteLocationFilterSet(data={"q": "amsterdam"}, queryset=[matching_item, non_matching_item])
result = fs.qs
assert len(result) == 1
assert result[0] is matching_item
def test_qs_empty_q_returns_all(self):
"""Empty string for q is falsy should return the full queryset."""
from netbox_librenms_plugin.filtersets import SiteLocationFilterSet
items = [MagicMock(), MagicMock()]
fs = SiteLocationFilterSet(data={"q": ""}, queryset=items)
assert fs.qs == items
def test_matches_by_site_name(self):
from netbox_librenms_plugin.filtersets import SiteLocationFilterSet
item = MagicMock()
item.netbox_site.name = "TestSite"
item.netbox_site.latitude = "0"
item.netbox_site.longitude = "0"
item.librenms_location = None
fs = SiteLocationFilterSet(data={"q": "testsite"}, queryset=[item])
assert fs.qs == [item]
def test_matches_by_latitude(self):
from netbox_librenms_plugin.filtersets import SiteLocationFilterSet
item = MagicMock()
item.netbox_site.name = "Nowhere"
item.netbox_site.latitude = "48.8566"
item.netbox_site.longitude = "0.0"
item.librenms_location = None
fs = SiteLocationFilterSet(data={"q": "48.8566"}, queryset=[item])
assert fs.qs == [item]
def test_matches_by_librenms_location(self):
from netbox_librenms_plugin.filtersets import SiteLocationFilterSet
item = MagicMock()
item.netbox_site.name = "X"
item.netbox_site.latitude = "0"
item.netbox_site.longitude = "0"
item.librenms_location = "Paris-DC"
fs = SiteLocationFilterSet(data={"q": "paris"}, queryset=[item])
assert fs.qs == [item]
def test_no_match_returns_empty(self):
from netbox_librenms_plugin.filtersets import SiteLocationFilterSet
item = MagicMock()
item.netbox_site.name = "Tokyo"
item.netbox_site.latitude = "35.0"
item.netbox_site.longitude = "139.0"
item.librenms_location = "TKY-1"
fs = SiteLocationFilterSet(data={"q": "berlin"}, queryset=[item])
assert fs.qs == []
def test_librenms_location_none_does_not_raise(self):
from netbox_librenms_plugin.filtersets import SiteLocationFilterSet
item = MagicMock()
item.netbox_site.name = "NoLocation"
item.netbox_site.latitude = "10"
item.netbox_site.longitude = "20"
item.librenms_location = None
fs = SiteLocationFilterSet(data={"q": "nolocation"}, queryset=[item])
# Should not raise, librenms_location treated as empty string
result = fs.qs
assert len(result) == 1
def test_form_property_returns_bound_form(self):
from netbox_librenms_plugin.filtersets import SiteLocationFilterSet
from django import forms
fs = SiteLocationFilterSet(data={"q": "test"}, queryset=[])
form = fs.form
assert isinstance(form, forms.Form)
assert form.is_bound
assert "q" in form.fields
def test_form_property_returns_unbound_form_when_no_data(self):
from netbox_librenms_plugin.filtersets import SiteLocationFilterSet
from django import forms
fs = SiteLocationFilterSet(data=None, queryset=[])
form = fs.form
assert isinstance(form, forms.Form)
assert not form.is_bound
# ===========================================================================
# filtersets.py DeviceStatusFilterSet.search()
# ===========================================================================
class TestDeviceStatusFilterSetSearch:
"""Tests for DeviceStatusFilterSet.search()."""
def test_search_empty_value_returns_queryset_unchanged(self):
from netbox_librenms_plugin.filtersets import DeviceStatusFilterSet
fs = object.__new__(DeviceStatusFilterSet)
mock_qs = MagicMock()
result = fs.search(mock_qs, "name", " ")
assert result is mock_qs
mock_qs.filter.assert_not_called()
def test_search_with_value_calls_filter(self):
from netbox_librenms_plugin.filtersets import DeviceStatusFilterSet
fs = object.__new__(DeviceStatusFilterSet)
mock_qs = MagicMock()
mock_qs.filter.return_value = mock_qs
result = fs.search(mock_qs, "name", "router01")
mock_qs.filter.assert_called_once()
assert result is mock_qs
def test_search_builds_q_filter_for_name(self):
from netbox_librenms_plugin.filtersets import DeviceStatusFilterSet
fs = object.__new__(DeviceStatusFilterSet)
mock_qs = MagicMock()
mock_qs.filter.return_value = mock_qs
fs.search(mock_qs, "name", "router")
call_args = mock_qs.filter.call_args
assert call_args is not None
q_obj = call_args[0][0]
q_str = str(q_obj)
assert "name__icontains" in q_str
assert "site__name__icontains" in q_str
assert "device_type__model__icontains" in q_str
assert "role__name__icontains" in q_str
assert "rack__name__icontains" in q_str
def test_search_whitespace_only_returns_qs(self):
from netbox_librenms_plugin.filtersets import DeviceStatusFilterSet
fs = object.__new__(DeviceStatusFilterSet)
mock_qs = MagicMock()
result = fs.search(mock_qs, "q", "\t\n")
assert result is mock_qs
# ===========================================================================
# filtersets.py VMStatusFilterSet.search()
# ===========================================================================
class TestVMStatusFilterSetSearch:
"""Tests for VMStatusFilterSet.search()."""
def test_search_empty_value_returns_queryset_unchanged(self):
from netbox_librenms_plugin.filtersets import VMStatusFilterSet
fs = object.__new__(VMStatusFilterSet)
mock_qs = MagicMock()
result = fs.search(mock_qs, "name", "")
assert result is mock_qs
mock_qs.filter.assert_not_called()
def test_search_with_value_calls_filter(self):
from netbox_librenms_plugin.filtersets import VMStatusFilterSet
fs = object.__new__(VMStatusFilterSet)
mock_qs = MagicMock()
mock_qs.filter.return_value = mock_qs
result = fs.search(mock_qs, "name", "vm-prod-01")
mock_qs.filter.assert_called_once()
assert result is mock_qs
def test_search_builds_filter_with_name_site_cluster_platform(self):
from netbox_librenms_plugin.filtersets import VMStatusFilterSet
fs = object.__new__(VMStatusFilterSet)
mock_qs = MagicMock()
mock_qs.filter.return_value = mock_qs
fs.search(mock_qs, "q", "production")
call_args = mock_qs.filter.call_args
assert call_args is not None
q_obj = call_args[0][0]
q_str = str(q_obj)
assert "name__icontains" in q_str
assert "site__name__icontains" in q_str
assert "cluster__name__icontains" in q_str
assert "platform__name__icontains" in q_str
def test_search_whitespace_only_returns_qs(self):
from netbox_librenms_plugin.filtersets import VMStatusFilterSet
fs = object.__new__(VMStatusFilterSet)
mock_qs = MagicMock()
result = fs.search(mock_qs, "q", " ")
assert result is mock_qs
# ===========================================================================
# models.py missing lines 45, 48, 68, 76
# ===========================================================================
class TestLibreNMSSettingsModel:
"""Tests for LibreNMSSettings model methods (lines 45, 48)."""
def test_get_absolute_url_calls_reverse(self):
"""Line 45: get_absolute_url() returns the settings page URL."""
from netbox_librenms_plugin.models import LibreNMSSettings
instance = object.__new__(LibreNMSSettings)
instance.selected_server = "default"
with patch("netbox_librenms_plugin.models.reverse") as mock_reverse:
mock_reverse.return_value = "/plugins/librenms/settings/"
url = instance.get_absolute_url()
mock_reverse.assert_called_once_with("plugins:netbox_librenms_plugin:settings")
assert url == "/plugins/librenms/settings/"
def test_str_returns_formatted_string(self):
"""Line 48: __str__() includes selected_server name."""
from netbox_librenms_plugin.models import LibreNMSSettings
instance = object.__new__(LibreNMSSettings)
instance.selected_server = "my_server"
result = str(instance)
assert result == "LibreNMS Settings - Server: my_server"
def test_str_with_default_server(self):
"""__str__() works with 'default' server."""
from netbox_librenms_plugin.models import LibreNMSSettings
instance = object.__new__(LibreNMSSettings)
instance.selected_server = "default"
assert str(instance) == "LibreNMS Settings - Server: default"
class TestInterfaceTypeMappingModel:
"""Tests for InterfaceTypeMapping model methods (lines 68, 76)."""
def test_get_absolute_url_calls_reverse_with_pk(self):
"""Line 68: get_absolute_url() passes self.pk to reverse."""
from netbox_librenms_plugin.models import InterfaceTypeMapping
instance = object.__new__(InterfaceTypeMapping)
instance.pk = 42
with patch("netbox_librenms_plugin.models.reverse") as mock_reverse:
mock_reverse.return_value = "/plugins/librenms/mappings/42/"
url = instance.get_absolute_url()
mock_reverse.assert_called_once_with(
"plugins:netbox_librenms_plugin:interfacetypemapping_detail",
args=[42],
)
assert url == "/plugins/librenms/mappings/42/"
def test_str_returns_type_speed_netbox_type(self):
"""Line 76: __str__() formats librenms_type + librenms_speed -> netbox_type."""
from netbox_librenms_plugin.models import InterfaceTypeMapping
instance = object.__new__(InterfaceTypeMapping)
instance.librenms_type = "ethernet"
instance.librenms_speed = 1000000
instance.netbox_type = "1000base-t"
result = str(instance)
assert result == "ethernet + 1000000 -> 1000base-t"
def test_str_with_none_speed(self):
"""__str__() works when librenms_speed is None."""
from netbox_librenms_plugin.models import InterfaceTypeMapping
instance = object.__new__(InterfaceTypeMapping)
instance.librenms_type = "fiber"
instance.librenms_speed = None
instance.netbox_type = "other"
result = str(instance)
assert result == "fiber + None -> other"
def test_get_absolute_url_with_different_pk(self):
"""get_absolute_url() works for any pk value."""
from netbox_librenms_plugin.models import InterfaceTypeMapping
instance = object.__new__(InterfaceTypeMapping)
instance.pk = 1
with patch("netbox_librenms_plugin.models.reverse") as mock_reverse:
mock_reverse.return_value = "/plugins/librenms/mappings/1/"
url = instance.get_absolute_url()
assert url == "/plugins/librenms/mappings/1/"

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,320 @@
"""Coverage tests for netbox_librenms_plugin.import_utils.cache module."""
from unittest.mock import patch
class TestGetLocationChoicesCacheKey:
"""Tests for get_location_choices_cache_key (line 14)."""
def test_returns_correct_format(self):
from netbox_librenms_plugin.import_utils.cache import get_location_choices_cache_key
result = get_location_choices_cache_key("default")
assert result == "librenms_locations_choices:default"
def test_different_server_keys(self):
from netbox_librenms_plugin.import_utils.cache import get_location_choices_cache_key
assert get_location_choices_cache_key("primary") == "librenms_locations_choices:primary"
assert get_location_choices_cache_key("secondary") == "librenms_locations_choices:secondary"
class TestGetActiveCachedSearches:
"""Tests for get_active_cached_searches (lines 52-131)."""
@patch("netbox_librenms_plugin.import_utils.cache.cache")
def test_empty_cache_index_returns_empty_list(self, mock_cache):
from netbox_librenms_plugin.import_utils.cache import get_active_cached_searches
mock_cache.get.return_value = []
result = get_active_cached_searches("default")
assert result == []
@patch("netbox_librenms_plugin.import_utils.cache.cache")
def test_none_cache_index_returns_empty_list(self, mock_cache):
from netbox_librenms_plugin.import_utils.cache import get_active_cached_searches
# cache.get(cache_index_key, []) returns [] when cache misses
mock_cache.get.side_effect = lambda key, default=None: default if "cache_index" in key else None
result = get_active_cached_searches("default")
assert result == []
@patch("netbox_librenms_plugin.import_utils.cache.cache")
def test_entry_with_remaining_time_is_returned(self, mock_cache):
from datetime import datetime, timezone
from netbox_librenms_plugin.import_utils.cache import get_active_cached_searches
now = datetime.now(timezone.utc)
cached_at = now.isoformat()
def mock_get(key, default=None):
if "cache_index" in key:
return ["some_cache_key"]
if "librenms_locations_choices" in key:
return None
if key == "some_cache_key":
return {
"cache_timeout": 300,
"cached_at": cached_at,
"filters": {},
}
return default
mock_cache.get.side_effect = mock_get
result = get_active_cached_searches("default")
assert len(result) == 1
assert result[0]["remaining_seconds"] > 0
assert result[0]["cache_key"] == "some_cache_key"
assert result[0]["display_filters"] == {}
@patch("netbox_librenms_plugin.import_utils.cache.cache")
def test_expired_entry_is_cleaned_up(self, mock_cache):
from datetime import datetime, timezone
from netbox_librenms_plugin.import_utils.cache import get_active_cached_searches
# Cached at epoch (way in the past)
old_time = datetime.fromtimestamp(0, timezone.utc).isoformat()
def mock_get(key, default=None):
if "cache_index" in key:
return ["expired_key"]
if "librenms_locations_choices" in key:
return None
if key == "expired_key":
return {
"cache_timeout": 300,
"cached_at": old_time,
"filters": {},
}
return default
mock_cache.get.side_effect = mock_get
result = get_active_cached_searches("default")
# Expired entries should NOT be in results
assert result == []
# Cache index should be updated to remove expired keys
mock_cache.set.assert_called_once()
call_args = mock_cache.set.call_args
assert "cache_index" in call_args[0][0]
assert call_args[0][1] == []
@patch("netbox_librenms_plugin.import_utils.cache.cache")
def test_location_id_enriched_from_cache(self, mock_cache):
from datetime import datetime, timezone
from netbox_librenms_plugin.import_utils.cache import get_active_cached_searches
now = datetime.now(timezone.utc)
cached_at = now.isoformat()
def mock_get(key, default=None):
if "cache_index" in key:
return ["search_key"]
if key == "librenms_locations_choices:default":
return [("42", "New York DC"), ("99", "London DC")]
if key == "search_key":
return {
"cache_timeout": 300,
"cached_at": cached_at,
"filters": {"location": "42"},
}
return default
mock_cache.get.side_effect = mock_get
result = get_active_cached_searches("default")
assert len(result) == 1
assert result[0]["display_filters"]["location"] == "New York DC"
@patch("netbox_librenms_plugin.import_utils.cache.cache")
def test_type_code_enriched_to_display_name(self, mock_cache):
from datetime import datetime, timezone
from netbox_librenms_plugin.import_utils.cache import get_active_cached_searches
now = datetime.now(timezone.utc)
cached_at = now.isoformat()
def mock_get(key, default=None):
if "cache_index" in key:
return ["search_key"]
if "librenms_locations_choices" in key:
return None
if key == "search_key":
return {
"cache_timeout": 300,
"cached_at": cached_at,
"filters": {"type": "network"},
}
return default
mock_cache.get.side_effect = mock_get
result = get_active_cached_searches("default")
assert len(result) == 1
assert result[0]["display_filters"]["type"] == "Network"
@patch("netbox_librenms_plugin.import_utils.cache.cache")
def test_missing_filters_key_falls_back_to_empty_dict(self, mock_cache):
from datetime import datetime, timezone
from netbox_librenms_plugin.import_utils.cache import get_active_cached_searches
now = datetime.now(timezone.utc)
cached_at = now.isoformat()
def mock_get(key, default=None):
if "cache_index" in key:
return ["search_key"]
if "librenms_locations_choices" in key:
return None
if key == "search_key":
# No 'filters' key
return {
"cache_timeout": 300,
"cached_at": cached_at,
}
return default
mock_cache.get.side_effect = mock_get
result = get_active_cached_searches("default")
assert len(result) == 1
assert result[0]["display_filters"] == {}
@patch("netbox_librenms_plugin.import_utils.cache.cache")
def test_timezone_naive_cached_at_normalized_to_utc(self, mock_cache):
from netbox_librenms_plugin.import_utils.cache import get_active_cached_searches
# naive datetime string (no tzinfo)
naive_ts = "2099-01-01T12:00:00"
def mock_get(key, default=None):
if "cache_index" in key:
return ["search_key"]
if "librenms_locations_choices" in key:
return None
if key == "search_key":
return {
"cache_timeout": 99999999,
"cached_at": naive_ts,
"filters": {},
}
return default
mock_cache.get.side_effect = mock_get
result = get_active_cached_searches("default")
# Should not raise; remaining_seconds should be > 0
assert len(result) == 1
assert result[0]["remaining_seconds"] > 0
@patch("netbox_librenms_plugin.import_utils.cache.cache")
def test_malformed_cached_at_falls_back_to_epoch(self, mock_cache):
from netbox_librenms_plugin.import_utils.cache import get_active_cached_searches
def mock_get(key, default=None):
if "cache_index" in key:
return ["search_key"]
if "librenms_locations_choices" in key:
return None
if key == "search_key":
return {
"cache_timeout": 300,
"cached_at": "NOT_A_VALID_DATETIME",
"filters": {},
}
return default
mock_cache.get.side_effect = mock_get
# malformed cached_at → epoch → expired → empty result
result = get_active_cached_searches("default")
assert result == []
@patch("netbox_librenms_plugin.import_utils.cache.cache")
def test_metadata_none_skipped(self, mock_cache):
"""Cache key in index but metadata is None → skip."""
from netbox_librenms_plugin.import_utils.cache import get_active_cached_searches
def mock_get(key, default=None):
if "cache_index" in key:
return ["gone_key"]
if "librenms_locations_choices" in key:
return None
# metadata expired from cache
return default
mock_cache.get.side_effect = mock_get
result = get_active_cached_searches("default")
assert result == []
# Should update index to remove the gone key
mock_cache.set.assert_called_once()
@patch("netbox_librenms_plugin.import_utils.cache.cache")
def test_results_sorted_by_cached_at_most_recent_first(self, mock_cache):
from datetime import datetime, timedelta, timezone
from netbox_librenms_plugin.import_utils.cache import get_active_cached_searches
now = datetime.now(timezone.utc)
older = (now - timedelta(seconds=60)).isoformat()
newer = now.isoformat()
def mock_get(key, default=None):
if "cache_index" in key:
return ["older_key", "newer_key"]
if "librenms_locations_choices" in key:
return None
if key == "older_key":
return {"cache_timeout": 300, "cached_at": older, "filters": {}}
if key == "newer_key":
return {"cache_timeout": 300, "cached_at": newer, "filters": {}}
return default
mock_cache.get.side_effect = mock_get
result = get_active_cached_searches("default")
assert len(result) == 2
assert result[0]["cached_at"] >= result[1]["cached_at"]
class TestGetCacheMetadataKeyDeterminism:
"""Tests that get_cache_metadata_key is deterministic."""
def test_different_filter_values_produce_different_keys(self):
"""Different filter values should produce different cache keys."""
from netbox_librenms_plugin.import_utils.cache import get_cache_metadata_key
key1 = get_cache_metadata_key("default", {"location": "NYC"}, False)
key2 = get_cache_metadata_key("default", {"location": "LON"}, False)
assert key1 != key2
def test_same_filters_produce_same_key(self):
"""Same filters in any insertion order should produce the same cache key."""
from netbox_librenms_plugin.import_utils.cache import get_cache_metadata_key
key1 = get_cache_metadata_key("default", {"location": "NYC", "type": "network"}, True)
key2 = get_cache_metadata_key("default", {"type": "network", "location": "NYC"}, True)
assert key1 == key2
def test_none_values_excluded_from_hash(self):
"""None filter values should be excluded and produce same key as absent."""
from netbox_librenms_plugin.import_utils.cache import get_cache_metadata_key
key_with_none = get_cache_metadata_key("default", {"location": "NYC", "type": None}, False)
key_without = get_cache_metadata_key("default", {"location": "NYC"}, False)
assert key_with_none == key_without
def test_different_server_keys_produce_different_keys(self):
"""Different server keys should produce different cache metadata keys."""
from netbox_librenms_plugin.import_utils.cache import get_cache_metadata_key
key1 = get_cache_metadata_key("production", {"location": "NYC"}, False)
key2 = get_cache_metadata_key("staging", {"location": "NYC"}, False)
assert key1 != key2

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,768 @@
"""Coverage tests for netbox_librenms_plugin.import_utils.filters module."""
from unittest.mock import MagicMock, patch
class TestGetDeviceCountForFilters:
"""Tests for get_device_count_for_filters (line 101)."""
@patch("netbox_librenms_plugin.import_utils.filters.get_librenms_devices_for_import")
def test_returns_device_count(self, mock_get):
from netbox_librenms_plugin.import_utils.filters import get_device_count_for_filters
mock_get.return_value = [{"device_id": 1}, {"device_id": 2}]
api = MagicMock()
result = get_device_count_for_filters(api, {})
assert result == 2
@patch("netbox_librenms_plugin.import_utils.filters.get_librenms_devices_for_import")
def test_excludes_disabled_when_show_disabled_false(self, mock_get):
from netbox_librenms_plugin.import_utils.filters import get_device_count_for_filters
mock_get.return_value = [
{"device_id": 1, "disabled": 0},
{"device_id": 2, "disabled": 1},
]
api = MagicMock()
result = get_device_count_for_filters(api, {}, show_disabled=False)
assert result == 1
@patch("netbox_librenms_plugin.import_utils.filters.get_librenms_devices_for_import")
def test_includes_disabled_when_show_disabled_true(self, mock_get):
from netbox_librenms_plugin.import_utils.filters import get_device_count_for_filters
mock_get.return_value = [
{"device_id": 1, "disabled": 0},
{"device_id": 2, "disabled": 1},
]
api = MagicMock()
result = get_device_count_for_filters(api, {}, show_disabled=True)
assert result == 2
@patch("netbox_librenms_plugin.import_utils.filters.get_librenms_devices_for_import")
def test_passes_force_refresh_as_force_refresh(self, mock_get):
from netbox_librenms_plugin.import_utils.filters import get_device_count_for_filters
mock_get.return_value = []
api = MagicMock()
get_device_count_for_filters(api, {}, clear_cache=True)
mock_get.assert_called_once_with(api, filters={}, force_refresh=True)
class TestGetLibreNMSDevicesForImport:
"""Tests for get_librenms_devices_for_import (lines 112-244)."""
@patch("netbox_librenms_plugin.import_utils.filters.cache")
def test_status_filter_up(self, mock_cache):
from netbox_librenms_plugin.import_utils.filters import get_librenms_devices_for_import
mock_cache.get.return_value = None
api = MagicMock()
api.server_key = "default"
api.cache_timeout = 300
api.list_devices.return_value = (True, [{"device_id": 1}])
get_librenms_devices_for_import(api, filters={"status": "1"})
call_args = api.list_devices.call_args[0][0]
assert call_args["type"] == "up"
@patch("netbox_librenms_plugin.import_utils.filters.cache")
def test_status_filter_down(self, mock_cache):
from netbox_librenms_plugin.import_utils.filters import get_librenms_devices_for_import
mock_cache.get.return_value = None
api = MagicMock()
api.server_key = "default"
api.cache_timeout = 300
api.list_devices.return_value = (True, [{"device_id": 1}])
get_librenms_devices_for_import(api, filters={"status": "0"})
call_args = api.list_devices.call_args[0][0]
assert call_args["type"] == "down"
@patch("netbox_librenms_plugin.import_utils.filters.cache")
def test_location_filter_goes_to_api(self, mock_cache):
from netbox_librenms_plugin.import_utils.filters import get_librenms_devices_for_import
mock_cache.get.return_value = None
api = MagicMock()
api.server_key = "default"
api.cache_timeout = 300
api.list_devices.return_value = (True, [])
get_librenms_devices_for_import(api, filters={"location": "10"})
call_args = api.list_devices.call_args[0][0]
assert call_args["type"] == "location_id"
assert call_args["query"] == "10"
@patch("netbox_librenms_plugin.import_utils.filters.cache")
def test_type_filter_goes_to_api(self, mock_cache):
from netbox_librenms_plugin.import_utils.filters import get_librenms_devices_for_import
mock_cache.get.return_value = None
api = MagicMock()
api.server_key = "default"
api.cache_timeout = 300
api.list_devices.return_value = (True, [])
get_librenms_devices_for_import(api, filters={"type": "network"})
call_args = api.list_devices.call_args[0][0]
assert call_args["type"] == "type"
assert call_args["query"] == "network"
@patch("netbox_librenms_plugin.import_utils.filters.cache")
def test_os_filter_goes_to_api(self, mock_cache):
from netbox_librenms_plugin.import_utils.filters import get_librenms_devices_for_import
mock_cache.get.return_value = None
api = MagicMock()
api.server_key = "default"
api.cache_timeout = 300
api.list_devices.return_value = (True, [])
get_librenms_devices_for_import(api, filters={"os": "ios"})
call_args = api.list_devices.call_args[0][0]
assert call_args["type"] == "os"
assert call_args["query"] == "ios"
@patch("netbox_librenms_plugin.import_utils.filters.cache")
def test_hostname_filter_goes_to_api(self, mock_cache):
from netbox_librenms_plugin.import_utils.filters import get_librenms_devices_for_import
mock_cache.get.return_value = None
api = MagicMock()
api.server_key = "default"
api.cache_timeout = 300
api.list_devices.return_value = (True, [])
get_librenms_devices_for_import(api, filters={"hostname": "router1"})
call_args = api.list_devices.call_args[0][0]
assert call_args["type"] == "hostname"
assert call_args["query"] == "router1"
@patch("netbox_librenms_plugin.import_utils.filters.cache")
def test_sysname_filter_goes_to_api(self, mock_cache):
from netbox_librenms_plugin.import_utils.filters import get_librenms_devices_for_import
mock_cache.get.return_value = None
api = MagicMock()
api.server_key = "default"
api.cache_timeout = 300
api.list_devices.return_value = (True, [])
get_librenms_devices_for_import(api, filters={"sysname": "core-sw"})
call_args = api.list_devices.call_args[0][0]
assert call_args["type"] == "sysName"
assert call_args["query"] == "core-sw"
@patch("netbox_librenms_plugin.import_utils.filters.cache")
def test_hardware_filter_goes_to_client_side(self, mock_cache):
from netbox_librenms_plugin.import_utils.filters import get_librenms_devices_for_import
mock_cache.get.return_value = None
api = MagicMock()
api.server_key = "default"
api.cache_timeout = 300
api.list_devices.return_value = (
True,
[
{"hardware": "Cisco C9300", "device_id": 1},
{"hardware": "Other Device", "device_id": 2},
],
)
result = get_librenms_devices_for_import(api, filters={"hardware": "C9300"})
# API gets no filters
api.list_devices.assert_called_once_with(None)
# Only the matching device survives client-side filtering
assert len(result) == 1
assert result[0]["device_id"] == 1
assert 2 not in [d["device_id"] for d in result]
@patch("netbox_librenms_plugin.import_utils.filters.cache")
def test_location_plus_type_location_to_api_type_to_client(self, mock_cache):
from netbox_librenms_plugin.import_utils.filters import get_librenms_devices_for_import
mock_cache.get.return_value = None
api = MagicMock()
api.server_key = "default"
api.cache_timeout = 300
devices = [
{"device_id": 1, "type": "network", "location_id": 10},
{"device_id": 2, "type": "server", "location_id": 10},
]
api.list_devices.return_value = (True, devices)
result = get_librenms_devices_for_import(api, filters={"location": "10", "type": "network"})
call_args = api.list_devices.call_args[0][0]
# location goes to API
assert call_args["type"] == "location_id"
# only the matching device survives client-side type filter
assert len(result) == 1
assert result[0]["device_id"] == 1
assert 2 not in [d["device_id"] for d in result]
@patch("netbox_librenms_plugin.import_utils.filters.cache")
def test_force_refresh_deletes_cache(self, mock_cache):
from netbox_librenms_plugin.import_utils.filters import get_librenms_devices_for_import
api = MagicMock()
api.server_key = "default"
api.cache_timeout = 300
api.list_devices.return_value = (True, [])
get_librenms_devices_for_import(api, filters={}, force_refresh=True)
mock_cache.delete.assert_called_once()
@patch("netbox_librenms_plugin.import_utils.filters.cache")
def test_cache_hit_returns_early_with_from_cache_true(self, mock_cache):
from netbox_librenms_plugin.import_utils.filters import get_librenms_devices_for_import
cached_devices = [{"device_id": 1}]
mock_cache.get.return_value = cached_devices
api = MagicMock()
api.server_key = "default"
result, from_cache = get_librenms_devices_for_import(api, filters={}, return_cache_status=True)
assert from_cache is True
assert result == cached_devices
api.list_devices.assert_not_called()
@patch("netbox_librenms_plugin.import_utils.filters.cache")
def test_api_failure_returns_empty_list(self, mock_cache):
from netbox_librenms_plugin.import_utils.filters import get_librenms_devices_for_import
mock_cache.get.return_value = None
api = MagicMock()
api.server_key = "default"
api.cache_timeout = 300
api.list_devices.return_value = (False, "Connection error")
result = get_librenms_devices_for_import(api, filters={})
assert result == []
@patch("netbox_librenms_plugin.import_utils.filters.cache")
def test_api_failure_with_return_cache_status(self, mock_cache):
from netbox_librenms_plugin.import_utils.filters import get_librenms_devices_for_import
mock_cache.get.return_value = None
api = MagicMock()
api.server_key = "default"
api.cache_timeout = 300
api.list_devices.return_value = (False, "Connection error")
result, from_cache = get_librenms_devices_for_import(api, filters={}, return_cache_status=True)
assert result == []
assert from_cache is False
@patch("netbox_librenms_plugin.import_utils.filters.cache")
def test_exception_returns_empty_list(self, mock_cache):
from netbox_librenms_plugin.import_utils.filters import get_librenms_devices_for_import
mock_cache.get.return_value = None
api = MagicMock()
api.server_key = "default"
api.list_devices.side_effect = RuntimeError("Unexpected error")
result = get_librenms_devices_for_import(api, filters={})
assert result == []
@patch("netbox_librenms_plugin.import_utils.filters.cache")
def test_exception_with_return_cache_status(self, mock_cache):
from netbox_librenms_plugin.import_utils.filters import get_librenms_devices_for_import
mock_cache.get.return_value = None
api = MagicMock()
api.server_key = "default"
api.list_devices.side_effect = RuntimeError("Unexpected error")
result, from_cache = get_librenms_devices_for_import(api, filters={}, return_cache_status=True)
assert result == []
assert from_cache is False
@patch("netbox_librenms_plugin.import_utils.filters.cache")
def test_success_caches_result(self, mock_cache):
from netbox_librenms_plugin.import_utils.filters import get_librenms_devices_for_import
mock_cache.get.return_value = None
api = MagicMock()
api.server_key = "default"
api.cache_timeout = 300
devices = [{"device_id": 1}]
api.list_devices.return_value = (True, devices)
get_librenms_devices_for_import(api, filters={})
mock_cache.set.assert_called_once()
@patch("netbox_librenms_plugin.import_utils.filters.cache")
def test_creates_api_when_none_provided(self, mock_cache):
from netbox_librenms_plugin.import_utils.filters import get_librenms_devices_for_import
mock_cache.get.return_value = None
mock_api_instance = MagicMock()
mock_api_instance.server_key = "default"
mock_api_instance.cache_timeout = 300
mock_api_instance.list_devices.return_value = (True, [])
with patch("netbox_librenms_plugin.import_utils.filters.LibreNMSAPI") as MockAPI:
MockAPI.return_value = mock_api_instance
get_librenms_devices_for_import(server_key="default")
MockAPI.assert_called_once_with(server_key="default")
@patch("netbox_librenms_plugin.import_utils.filters.cache")
def test_status_with_other_filters_go_to_client(self, mock_cache):
"""When status is set, all other filters go client-side."""
from netbox_librenms_plugin.import_utils.filters import get_librenms_devices_for_import
mock_cache.get.return_value = None
api = MagicMock()
api.server_key = "default"
api.cache_timeout = 300
devices = [{"device_id": 1, "type": "server", "location_id": 5}]
api.list_devices.return_value = (True, devices)
get_librenms_devices_for_import(api, filters={"status": "1", "location": "5", "type": "server"})
call_args = api.list_devices.call_args[0][0]
assert call_args["type"] == "up"
class TestApplyClientFilters:
"""Tests for _apply_client_filters (lines 258-284)."""
def test_filter_by_location(self):
from netbox_librenms_plugin.import_utils.filters import _apply_client_filters
devices = [
{"device_id": 1, "location_id": 10},
{"device_id": 2, "location_id": 20},
]
result = _apply_client_filters(devices, {"location": "10"})
assert len(result) == 1
assert result[0]["device_id"] == 1
def test_filter_by_type(self):
from netbox_librenms_plugin.import_utils.filters import _apply_client_filters
devices = [
{"device_id": 1, "type": "network"},
{"device_id": 2, "type": "server"},
]
result = _apply_client_filters(devices, {"type": "network"})
assert len(result) == 1
assert result[0]["device_id"] == 1
def test_filter_by_os(self):
from netbox_librenms_plugin.import_utils.filters import _apply_client_filters
devices = [
{"device_id": 1, "os": "ios"},
{"device_id": 2, "os": "linux"},
]
result = _apply_client_filters(devices, {"os": "ios"})
assert len(result) == 1
def test_filter_by_hostname(self):
from netbox_librenms_plugin.import_utils.filters import _apply_client_filters
devices = [
{"device_id": 1, "hostname": "router01.example.com"},
{"device_id": 2, "hostname": "switch01.example.com"},
]
result = _apply_client_filters(devices, {"hostname": "router"})
assert len(result) == 1
assert result[0]["device_id"] == 1
def test_filter_by_sysname(self):
from netbox_librenms_plugin.import_utils.filters import _apply_client_filters
devices = [
{"device_id": 1, "sysName": "core-router"},
{"device_id": 2, "sysName": "access-switch"},
]
result = _apply_client_filters(devices, {"sysname": "core"})
assert len(result) == 1
def test_filter_by_hardware(self):
from netbox_librenms_plugin.import_utils.filters import _apply_client_filters
devices = [
{"device_id": 1, "hardware": "Cisco C9300-48P"},
{"device_id": 2, "hardware": "Juniper MX480"},
]
result = _apply_client_filters(devices, {"hardware": "C9300"})
assert len(result) == 1
def test_hardware_none_value_handled(self):
from netbox_librenms_plugin.import_utils.filters import _apply_client_filters
devices = [
{"device_id": 1, "hardware": None},
{"device_id": 2, "hardware": "Cisco C9300"},
]
result = _apply_client_filters(devices, {"hardware": "C9300"})
assert len(result) == 1
assert result[0]["device_id"] == 2
def test_no_filters_returns_all(self):
from netbox_librenms_plugin.import_utils.filters import _apply_client_filters
devices = [{"device_id": 1}, {"device_id": 2}]
result = _apply_client_filters(devices, {})
assert len(result) == 2
class TestGetLibreNMSDevicesMoreCoverage:
"""More tests for missing filter branches."""
@patch("netbox_librenms_plugin.import_utils.filters.cache")
def test_status_invalid_string_sets_none(self, mock_cache):
"""Lines 116-117: ValueError/TypeError when status is not a valid int."""
from netbox_librenms_plugin.import_utils.filters import get_librenms_devices_for_import
mock_cache.get.return_value = None
api = MagicMock()
api.server_key = "default"
api.cache_timeout = 300
api.list_devices.return_value = (True, [{"device_id": 1}])
result = get_librenms_devices_for_import(api, filters={"status": "invalid_value"})
assert isinstance(result, list)
# Invalid status means api.list_devices is called with None (no API type filter)
api.list_devices.assert_called_once_with(None)
# The single device returned from the API is passed through unchanged
assert len(result) == 1
assert result[0]["device_id"] == 1
@patch("netbox_librenms_plugin.import_utils.filters.cache")
def test_status_with_all_other_filters_go_to_client(self, mock_cache):
"""Lines 130-136: When status set, all filters (loc/type/os/hostname/sysname/hw) go client-side."""
from netbox_librenms_plugin.import_utils.filters import get_librenms_devices_for_import
mock_cache.get.return_value = None
api = MagicMock()
api.server_key = "default"
api.cache_timeout = 300
api.list_devices.return_value = (
True,
[
{
"device_id": 1,
"type": "server",
"location_id": 5,
"os": "linux",
"hostname": "srv01",
"sysName": "srv01",
"hardware": "Dell",
},
{
"device_id": 2,
"type": "other",
"location_id": 99,
"os": "windows",
"hostname": "othersrv",
"sysName": "othersrv",
"hardware": "HP",
},
],
)
result = get_librenms_devices_for_import(
api,
filters={
"status": "1",
"location": "5",
"type": "server",
"os": "linux",
"hostname": "srv01",
"sysname": "srv01",
"hardware": "Dell",
},
)
assert isinstance(result, list)
# The matching device should be present, but the non-matching device should not
device_ids = [d["device_id"] for d in result]
assert 1 in device_ids
assert len(result) == 1
assert 2 not in device_ids
@patch("netbox_librenms_plugin.import_utils.filters.cache")
def test_location_with_remaining_client_filters(self, mock_cache):
"""Lines 150-156: location API filter with type/os/hostname/sysname/hardware as client filters."""
from netbox_librenms_plugin.import_utils.filters import get_librenms_devices_for_import
mock_cache.get.return_value = None
api = MagicMock()
api.server_key = "default"
api.cache_timeout = 300
api.list_devices.return_value = (
True,
[
{
"device_id": 1,
"type": "network",
"os": "ios",
"hostname": "router01",
"sysName": "router01",
"hardware": "Cisco",
"location_id": "5",
},
{
"device_id": 2,
"type": "network",
"os": "ios",
"hostname": "switch99",
"sysName": "switch99",
"hardware": "Cisco",
"location_id": "5",
},
],
)
result = get_librenms_devices_for_import(
api,
filters={
"location": "5",
"type": "network",
"os": "ios",
"hostname": "router01",
"sysname": "router01",
"hardware": "Cisco",
},
)
assert len(result) == 1
assert result[0]["device_id"] == 1
call_args = api.list_devices.call_args[0][0]
assert call_args["type"] == "location_id"
assert call_args["query"] == "5"
@patch("netbox_librenms_plugin.import_utils.filters.cache")
def test_type_filter_with_remaining_client_filters(self, mock_cache):
"""Lines 162-168: type API filter with os/hostname/sysname/hardware as client filters."""
from netbox_librenms_plugin.import_utils.filters import get_librenms_devices_for_import
mock_cache.get.return_value = None
api = MagicMock()
api.server_key = "default"
api.cache_timeout = 300
api.list_devices.return_value = (
True,
[
{
"device_id": 1,
"type": "network",
"os": "ios",
"hostname": "router01",
"sysName": "router01",
"hardware": "Cisco",
},
],
)
get_librenms_devices_for_import(
api,
filters={
"type": "network",
"os": "ios",
"hostname": "router01",
"sysname": "router01",
"hardware": "Cisco",
},
)
call_args = api.list_devices.call_args[0][0]
assert call_args["type"] == "type"
assert call_args["query"] == "network"
@patch("netbox_librenms_plugin.import_utils.filters.cache")
def test_os_filter_with_remaining_client_filters(self, mock_cache):
"""Lines 174-178: os API filter with hostname/sysname/hardware as client filters."""
from netbox_librenms_plugin.import_utils.filters import get_librenms_devices_for_import
mock_cache.get.return_value = None
api = MagicMock()
api.server_key = "default"
api.cache_timeout = 300
api.list_devices.return_value = (
True,
[
{"device_id": 1, "os": "ios", "hostname": "router01", "sysName": "router01", "hardware": "Cisco"},
],
)
get_librenms_devices_for_import(
api,
filters={
"os": "ios",
"hostname": "router01",
"sysname": "router01",
"hardware": "Cisco",
},
)
call_args = api.list_devices.call_args[0][0]
assert call_args["type"] == "os"
assert call_args["query"] == "ios"
@patch("netbox_librenms_plugin.import_utils.filters.cache")
def test_hostname_filter_with_sysname_and_hardware(self, mock_cache):
"""Lines 184-186: hostname API filter with sysname/hardware as client filters."""
from netbox_librenms_plugin.import_utils.filters import get_librenms_devices_for_import
mock_cache.get.return_value = None
api = MagicMock()
api.server_key = "default"
api.cache_timeout = 300
api.list_devices.return_value = (
True,
[
{"device_id": 1, "hostname": "router01", "sysName": "router01", "hardware": "Cisco"},
],
)
get_librenms_devices_for_import(
api,
filters={
"hostname": "router01",
"sysname": "router01",
"hardware": "Cisco",
},
)
call_args = api.list_devices.call_args[0][0]
assert call_args["type"] == "hostname"
@patch("netbox_librenms_plugin.import_utils.filters.cache")
def test_sysname_filter_with_hardware(self, mock_cache):
"""Line 194: sysname API filter with hardware as client filter."""
from netbox_librenms_plugin.import_utils.filters import get_librenms_devices_for_import
mock_cache.get.return_value = None
api = MagicMock()
api.server_key = "default"
api.cache_timeout = 300
api.list_devices.return_value = (
True,
[
{"device_id": 1, "sysName": "router01", "hardware": "Cisco"},
],
)
get_librenms_devices_for_import(
api,
filters={
"sysname": "router01",
"hardware": "Cisco",
},
)
call_args = api.list_devices.call_args[0][0]
assert call_args["type"] == "sysName"
@patch("netbox_librenms_plugin.import_utils.filters.cache")
def test_client_filters_applied_to_results(self, mock_cache):
"""Line 237: _apply_client_filters is called when client_filters is set."""
from netbox_librenms_plugin.import_utils.filters import get_librenms_devices_for_import
mock_cache.get.return_value = None
api = MagicMock()
api.server_key = "default"
api.cache_timeout = 300
# Two devices, one matches hardware filter, one doesn't
api.list_devices.return_value = (
True,
[
{"device_id": 1, "hardware": "Cisco C9300", "location_id": 5},
{"device_id": 2, "hardware": "Juniper MX480", "location_id": 5},
],
)
result = get_librenms_devices_for_import(
api,
filters={
"location": "5",
"hardware": "C9300", # Goes to client_filters
},
)
# Should only return the Cisco device after client filtering
assert len(result) == 1
assert result[0]["device_id"] == 1
class TestGetLibreNMSReturnCacheStatus:
"""Tests for return_cache_status path (line 237)."""
@patch("netbox_librenms_plugin.import_utils.filters.cache")
def test_return_cache_status_with_fresh_data(self, mock_cache):
"""Line 237: return devices, from_cache when return_cache_status=True."""
from netbox_librenms_plugin.import_utils.filters import get_librenms_devices_for_import
mock_cache.get.return_value = None
api = MagicMock()
api.server_key = "default"
api.cache_timeout = 300
api.list_devices.return_value = (True, [{"device_id": 1}])
result = get_librenms_devices_for_import(api, return_cache_status=True)
assert isinstance(result, tuple)
devices, from_cache = result
assert from_cache is False
assert len(devices) == 1
@patch("netbox_librenms_plugin.import_utils.filters.cache")
def test_return_cache_status_with_cached_data(self, mock_cache):
"""Line 218: return devices, from_cache when cache hit + return_cache_status=True."""
from netbox_librenms_plugin.import_utils.filters import get_librenms_devices_for_import
cached_devices = [{"device_id": 1}]
mock_cache.get.return_value = cached_devices
api = MagicMock()
api.server_key = "default"
api.cache_timeout = 300
result = get_librenms_devices_for_import(api, return_cache_status=True)
assert isinstance(result, tuple)
devices, from_cache = result
assert from_cache is True
@patch("netbox_librenms_plugin.import_utils.filters.cache")
def test_api_failure_with_return_cache_status(self, mock_cache):
"""Line 225: return [], False when API fails and return_cache_status=True."""
from netbox_librenms_plugin.import_utils.filters import get_librenms_devices_for_import
mock_cache.get.return_value = None
api = MagicMock()
api.server_key = "default"
api.cache_timeout = 300
api.list_devices.return_value = (False, "Error")
result = get_librenms_devices_for_import(api, return_cache_status=True)
assert isinstance(result, tuple)
devices, from_cache = result
assert devices == []
assert from_cache is False
class TestCacheKeyServerKeyIsolation:
"""Test that cache keys are isolated per server key (Thread 38)."""
@patch("netbox_librenms_plugin.import_utils.filters.cache")
def test_cache_key_uses_api_server_key(self, mock_cache):
"""Different server_keys produce different cache keys."""
from unittest.mock import MagicMock
from netbox_librenms_plugin.import_utils.filters import get_librenms_devices_for_import
mock_cache.get.return_value = None
api1 = MagicMock()
api1.server_key = "server1"
api1.cache_timeout = 300
api2 = MagicMock()
api2.server_key = "server2"
api2.cache_timeout = 300
api1.list_devices.return_value = (True, [])
api2.list_devices.return_value = (True, [])
get_librenms_devices_for_import(api1, filters={})
get_librenms_devices_for_import(api2, filters={})
assert mock_cache.set.call_count == 2
keys = [call.args[0] for call in mock_cache.set.call_args_list]
assert keys[0] != keys[1]

View File

@@ -0,0 +1,61 @@
"""Coverage tests for netbox_librenms_plugin.forms — has_option_only empty-data guard."""
from unittest.mock import patch
class TestLibreNMSFilterFormBackgroundJobDefault:
"""Tests for use_background_job default injection in LibreNMSImportFilterForm.__init__.
The form checks args[0] (positional) for the data dict, matching how Django
binds forms from request.GET/POST. Tests pass data positionally to match.
"""
def _make_form(self, data):
"""Instantiate LibreNMSImportFilterForm with mocked server settings.
Pass data as positional arg to match how Django provides request.GET.
"""
with (
patch("netbox_librenms_plugin.forms.LibreNMSImportFilterForm._populate_librenms_locations"),
):
from netbox_librenms_plugin.forms import LibreNMSImportFilterForm
# Pass data positionally — the form's __init__ checks args[0], not kwargs
return LibreNMSImportFilterForm(data)
def test_empty_data_sets_use_background_job_on(self):
"""LibreNMSImportFilterForm({}) should set use_background_job='on' (initial GET)."""
form = self._make_form({})
assert form.data.get("use_background_job") == "on"
def test_option_only_data_does_not_auto_set_background_job(self):
"""Submitting only option-only fields should NOT auto-set use_background_job."""
# show_disabled is an option-only field — not a real filter field
# has_option_only = bool({show_disabled}) and not non_option_fields and not has_filters
# = True and True and True = True → condition fails → use_background_job NOT injected
form = self._make_form({"show_disabled": "on"})
assert form.data.get("use_background_job") is None
def test_filter_data_does_not_auto_set_background_job(self):
"""When real filter fields are submitted, use_background_job is not auto-injected."""
form = self._make_form({"librenms_hostname": "switch01"})
assert form.data.get("use_background_job") is None
def test_use_background_job_preserved_when_already_set(self):
"""If use_background_job is already in data, it should not be overridden."""
form = self._make_form({"use_background_job": "off"})
assert form.data.get("use_background_job") == "off"
def test_no_positional_args_no_injection(self):
"""Unbound form (no positional args) should not inject use_background_job."""
with (
patch("netbox_librenms_plugin.forms.LibreNMSImportFilterForm._populate_librenms_locations"),
):
from netbox_librenms_plugin.forms import LibreNMSImportFilterForm
form = LibreNMSImportFilterForm()
assert form.data.get("use_background_job") is None
def test_pagination_param_does_not_inject_background_job(self):
"""Auxiliary params like 'page' must not trigger background-job default injection."""
form = self._make_form({"page": "2"})
assert form.data.get("use_background_job") is None

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,692 @@
"""Coverage tests for views/base/librenms_sync_view.py missing lines."""
from unittest.mock import MagicMock, patch
def _make_view():
"""Create a BaseLibreNMSSyncView instance bypassing __init__."""
from netbox_librenms_plugin.views.base.librenms_sync_view import BaseLibreNMSSyncView
view = object.__new__(BaseLibreNMSSyncView)
view.request = MagicMock()
view.tab = "librenms_sync"
view.model = MagicMock()
view.queryset = MagicMock()
view.kwargs = {}
view._librenms_api = MagicMock()
view._librenms_api.server_key = "default"
view._librenms_api.librenms_url = "https://x.example.com"
view._librenms_api.cache_timeout = 300
return view
class TestBaseLibreNMSSyncViewGet:
"""Tests for get() method (lines 29-53)."""
@patch("netbox_librenms_plugin.views.base.librenms_sync_view.render")
@patch("netbox_librenms_plugin.views.base.librenms_sync_view.get_object_or_404")
def test_get_non_vc_device(self, mock_get_obj, mock_render):
"""Non-VC device: librenms_lookup_device stays as obj."""
view = _make_view()
obj = MagicMock()
obj.virtual_chassis = None
mock_get_obj.return_value = obj
view._librenms_api = MagicMock()
view._librenms_api.server_key = "default"
view._librenms_api.get_librenms_id.return_value = 42
view.get_context_data = MagicMock(return_value={"test": "ctx"})
mock_render.return_value = MagicMock()
request = MagicMock()
view.get(request, pk=1)
# lookup device should be obj
assert view._librenms_lookup_device is obj
@patch("netbox_librenms_plugin.views.base.librenms_sync_view.render")
@patch("netbox_librenms_plugin.views.base.librenms_sync_view.get_object_or_404")
@patch("netbox_librenms_plugin.views.base.librenms_sync_view.get_librenms_sync_device")
def test_get_vc_member_always_delegates_to_sync_device(self, mock_get_sync, mock_get_obj, mock_render):
"""VC member: no own librenms_id - get_librenms_sync_device returns VC primary."""
view = _make_view()
obj = MagicMock()
obj.virtual_chassis = MagicMock()
mock_get_obj.return_value = obj
vc_primary = MagicMock() # Represents the VC primary device
mock_get_sync.return_value = vc_primary
view._librenms_api = MagicMock()
view._librenms_api.server_key = "default"
view._librenms_api.get_librenms_id.return_value = 99
view.get_context_data = MagicMock(return_value={})
mock_render.return_value = MagicMock()
request = MagicMock()
view.get(request, pk=1)
mock_get_sync.assert_called_once_with(obj, server_key="default")
# When member has no own ID, lookup uses the VC primary returned by get_librenms_sync_device
assert view._librenms_lookup_device is vc_primary
@patch("netbox_librenms_plugin.views.base.librenms_sync_view.render")
@patch("netbox_librenms_plugin.views.base.librenms_sync_view.get_object_or_404")
@patch("netbox_librenms_plugin.views.base.librenms_sync_view.get_librenms_sync_device")
def test_get_vc_member_with_own_librenms_id_uses_itself(self, mock_get_sync, mock_get_obj, mock_render):
"""VC member: has own librenms_id - get_librenms_sync_device still called, returns member itself."""
view = _make_view()
obj = MagicMock()
obj.virtual_chassis = MagicMock()
mock_get_obj.return_value = obj
# get_librenms_sync_device returns obj itself (member has own librenms_id, priority 1)
mock_get_sync.return_value = obj
view._librenms_api = MagicMock()
view._librenms_api.server_key = "default"
view._librenms_api.get_librenms_id.return_value = 55
view.get_context_data = MagicMock(return_value={})
mock_render.return_value = MagicMock()
request = MagicMock()
view.get(request, pk=1)
mock_get_sync.assert_called_once_with(obj, server_key="default")
# When member has its own ID, get_librenms_sync_device returns the member itself
assert view._librenms_lookup_device is obj
@patch("netbox_librenms_plugin.views.base.librenms_sync_view.render")
@patch("netbox_librenms_plugin.views.base.librenms_sync_view.get_object_or_404")
@patch("netbox_librenms_plugin.views.base.librenms_sync_view.get_librenms_sync_device")
def test_get_vc_member_no_sync_device_falls_back_to_obj(self, mock_get_sync, mock_get_obj, mock_render):
"""VC member: when get_librenms_sync_device returns None, keeps obj."""
view = _make_view()
obj = MagicMock()
obj.virtual_chassis = MagicMock()
mock_get_obj.return_value = obj
mock_get_sync.return_value = None
view._librenms_api = MagicMock()
view._librenms_api.server_key = "default"
view._librenms_api.get_librenms_id.return_value = 55
view.get_context_data = MagicMock(return_value={})
mock_render.return_value = MagicMock()
request = MagicMock()
view.get(request, pk=1)
mock_get_sync.assert_called_once_with(obj, server_key="default")
assert view._librenms_lookup_device is obj
class TestGetContextDataVC:
"""Tests for get_context_data() VC context (lines 69-91)."""
def test_vc_context_sync_device_has_id_and_ip(self):
"""VC device: sync_device_has_librenms_id and sync_device_has_primary_ip set."""
view = _make_view()
view.librenms_id = 42
view._librenms_lookup_device = MagicMock()
obj = MagicMock()
obj.virtual_chassis = MagicMock()
obj._meta = MagicMock()
obj._meta.model_name = "device"
sync_device = MagicMock()
sync_device.primary_ip = MagicMock()
sync_device._meta.model_name = "device"
sync_device.pk = 10
view._librenms_api = MagicMock()
view._librenms_api.server_key = "default"
view._librenms_api.librenms_url = "https://x.example.com"
view.get_librenms_device_info = MagicMock(
return_value={
"found_in_librenms": True,
"librenms_device_details": {
"librenms_device_serial": "SN001",
"librenms_device_hardware": "Cisco",
"librenms_device_os": "ios",
"librenms_device_version": "16.9",
"librenms_device_features": "-",
"librenms_device_location": "NYC",
"librenms_device_hardware_match": None,
"vc_inventory_serials": [],
},
"mismatched_device": False,
}
)
view.get_interface_context = MagicMock(return_value=None)
view.get_cable_context = MagicMock(return_value=None)
view.get_ip_context = MagicMock(return_value=None)
view.get_vlan_context = MagicMock(return_value=None)
with patch("netbox_librenms_plugin.views.base.librenms_sync_view.get_librenms_sync_device") as mock_sync:
mock_sync.return_value = sync_device
with patch("netbox_librenms_plugin.views.base.librenms_sync_view.get_librenms_device_id") as mock_id:
mock_id.return_value = 42
with patch(
"netbox_librenms_plugin.views.base.librenms_sync_view.get_interface_name_field",
return_value="ifName",
):
with patch(
"netbox_librenms_plugin.views.base.librenms_sync_view.BaseLibreNMSSyncView._build_all_server_mappings",
return_value=None,
):
with patch(
"netbox_librenms_plugin.views.base.librenms_sync_view.BaseLibreNMSSyncView._get_platform_info",
return_value={},
):
with patch("netbox_librenms_plugin.views.base.librenms_sync_view.AddToLIbreSNMPV1V2"):
with patch("netbox_librenms_plugin.views.base.librenms_sync_view.AddToLIbreSNMPV3"):
with patch("dcim.models.Manufacturer") as MockMfr:
MockMfr.objects.all.return_value.order_by.return_value = []
with patch.object(view, "get_context_data", wraps=view.get_context_data):
# Call parent get_context_data via a mock of super()
with patch(
"netbox_librenms_plugin.views.base.librenms_sync_view.LibreNMSAPIMixin.get_context_data",
return_value={},
):
ctx = view.get_context_data(MagicMock(), obj)
assert ctx.get("is_vc_member") is True
assert ctx.get("sync_device_has_librenms_id") is True
assert ctx.get("sync_device_has_primary_ip") is True
class TestBuildAllServerMappings:
"""Tests for _build_all_server_mappings (lines 181, 193, 200, 207-208)."""
def test_returns_none_for_non_dict_cf(self):
from netbox_librenms_plugin.views.base.librenms_sync_view import BaseLibreNMSSyncView
obj = MagicMock()
obj.custom_field_data = {"librenms_id": 42} # legacy bare int
result = BaseLibreNMSSyncView._build_all_server_mappings(obj, "default")
assert result is None
def test_returns_none_for_empty_dict_cf(self):
from netbox_librenms_plugin.views.base.librenms_sync_view import BaseLibreNMSSyncView
obj = MagicMock()
obj.custom_field_data = {"librenms_id": {}}
result = BaseLibreNMSSyncView._build_all_server_mappings(obj, "default")
assert result is None
def test_valid_dict_cf_returns_list(self):
from netbox_librenms_plugin.views.base.librenms_sync_view import BaseLibreNMSSyncView
obj = MagicMock()
obj.custom_field_data = {"librenms_id": {"default": 42, "secondary": 99}}
with patch("netbox_librenms_plugin.views.base.librenms_sync_view.django_settings") as mock_settings:
mock_settings.PLUGINS_CONFIG = {
"netbox_librenms_plugin": {
"servers": {
"default": {"librenms_url": "https://x.example.com", "display_name": "Default"},
"secondary": {"librenms_url": "https://y.example.com", "display_name": "Secondary"},
}
}
}
result = BaseLibreNMSSyncView._build_all_server_mappings(obj, "default")
assert result is not None
assert len(result) == 2
# Active server should be first
assert result[0]["is_active"] is True
assert result[0]["server_key"] == "default"
def test_bool_value_skipped(self):
from netbox_librenms_plugin.views.base.librenms_sync_view import BaseLibreNMSSyncView
obj = MagicMock()
obj.custom_field_data = {"librenms_id": {"default": True, "other": 42}}
with patch("netbox_librenms_plugin.views.base.librenms_sync_view.django_settings") as mock_settings:
mock_settings.PLUGINS_CONFIG = {
"netbox_librenms_plugin": {
"servers": {"other": {"librenms_url": "https://x.example.com", "display_name": "Other"}}
}
}
result = BaseLibreNMSSyncView._build_all_server_mappings(obj, "default")
assert result is not None
assert len(result) == 1
assert result[0]["server_key"] == "other"
def test_string_device_id_converted_to_int(self):
from netbox_librenms_plugin.views.base.librenms_sync_view import BaseLibreNMSSyncView
obj = MagicMock()
obj.custom_field_data = {"librenms_id": {"default": "77"}}
with patch("netbox_librenms_plugin.views.base.librenms_sync_view.django_settings") as mock_settings:
mock_settings.PLUGINS_CONFIG = {
"netbox_librenms_plugin": {
"servers": {"default": {"librenms_url": "https://x.example.com", "display_name": "Default"}}
}
}
result = BaseLibreNMSSyncView._build_all_server_mappings(obj, "default")
assert result[0]["device_id"] == 77
def test_non_digit_string_skipped(self):
from netbox_librenms_plugin.views.base.librenms_sync_view import BaseLibreNMSSyncView
obj = MagicMock()
obj.custom_field_data = {"librenms_id": {"default": "not-a-number"}}
with patch("netbox_librenms_plugin.views.base.librenms_sync_view.django_settings") as mock_settings:
mock_settings.PLUGINS_CONFIG = {"netbox_librenms_plugin": {"servers": {}}}
result = BaseLibreNMSSyncView._build_all_server_mappings(obj, "default")
assert result is None
def test_legacy_default_key_falls_back_to_root_librenms_url(self):
"""'default' key with no matching servers entry uses root librenms_url."""
from netbox_librenms_plugin.views.base.librenms_sync_view import BaseLibreNMSSyncView
obj = MagicMock()
obj.custom_field_data = {"librenms_id": {"default": 42}}
with patch("netbox_librenms_plugin.views.base.librenms_sync_view.django_settings") as mock_settings:
mock_settings.PLUGINS_CONFIG = {
"netbox_librenms_plugin": {
"librenms_url": "https://legacy.example.com",
"display_name": "Legacy Server",
"servers": {},
}
}
result = BaseLibreNMSSyncView._build_all_server_mappings(obj, "default")
assert result is not None
assert result[0]["librenms_url"] == "https://legacy.example.com"
def test_malformed_server_config_treated_as_unconfigured(self):
"""Non-dict server config entry → is_configured=False."""
from netbox_librenms_plugin.views.base.librenms_sync_view import BaseLibreNMSSyncView
obj = MagicMock()
obj.custom_field_data = {"librenms_id": {"default": 42}}
with patch("netbox_librenms_plugin.views.base.librenms_sync_view.django_settings") as mock_settings:
mock_settings.PLUGINS_CONFIG = {"netbox_librenms_plugin": {"servers": {"default": "this-is-not-a-dict"}}}
result = BaseLibreNMSSyncView._build_all_server_mappings(obj, "default")
assert result is not None
assert result[0]["is_configured"] is False
class TestGetLibreNMSDeviceInfo:
"""Tests for get_librenms_device_info (lines 228+)."""
def test_no_librenms_id_returns_defaults(self):
view = _make_view()
view.librenms_id = None
view._librenms_api = MagicMock()
obj = MagicMock()
result = view.get_librenms_device_info(obj)
assert result["found_in_librenms"] is False
assert result["mismatched_device"] is False
def test_librenms_id_success_sets_found(self):
view = _make_view()
view.librenms_id = 42
view._librenms_api = MagicMock()
view._librenms_api.librenms_url = "https://x.example.com"
obj = MagicMock()
obj.primary_ip = None
obj.name = "mydevice"
obj.virtual_chassis = None
obj.serial = "SN001"
obj.platform = None
device_info = {
"hardware": "Cisco C9300",
"serial": "SN001",
"os": "ios",
"version": "16.9",
"features": "-",
"sysName": "mydevice",
"hostname": "mydevice.example.com",
"ip": "10.0.0.1",
"location": "NYC",
}
view._librenms_api.get_device_info.return_value = (True, device_info)
with patch(
"netbox_librenms_plugin.views.base.librenms_sync_view.match_librenms_hardware_to_device_type"
) as mock_match:
mock_match.return_value = {"matched": False, "device_type": None, "match_type": None}
result = view.get_librenms_device_info(obj)
assert result["found_in_librenms"] is True
def test_mismatched_device_when_names_differ(self):
view = _make_view()
view.librenms_id = 42
view._librenms_api = MagicMock()
view._librenms_api.librenms_url = "https://x.example.com"
obj = MagicMock()
obj.primary_ip = None
obj.name = "device-netbox"
obj.virtual_chassis = None
obj.serial = ""
obj.platform = None
device_info = {
"hardware": "-",
"serial": "-",
"os": "-",
"version": "-",
"features": "-",
"sysName": "completely-different",
"hostname": "also-different.example.com",
"ip": "192.168.0.1",
"location": "-",
}
view._librenms_api.get_device_info.return_value = (True, device_info)
with patch(
"netbox_librenms_plugin.views.base.librenms_sync_view.match_librenms_hardware_to_device_type"
) as mock_match:
mock_match.return_value = {"matched": False, "device_type": None, "match_type": None}
with patch(
"netbox_librenms_plugin.views.base.librenms_sync_view.BaseLibreNMSSyncView._strip_vc_pattern",
return_value=None,
):
result = view.get_librenms_device_info(obj)
assert result["mismatched_device"] is True
class TestStripVcPattern:
"""Tests for _strip_vc_pattern (lines 378+)."""
def test_strips_default_pattern(self):
from netbox_librenms_plugin.views.base.librenms_sync_view import BaseLibreNMSSyncView
mock_settings_cls = MagicMock()
settings_obj = MagicMock()
settings_obj.vc_member_name_pattern = "-M{position}"
mock_settings_cls.objects.first.return_value = settings_obj
with patch("netbox_librenms_plugin.models.LibreNMSSettings", mock_settings_cls, create=True):
result = BaseLibreNMSSyncView._strip_vc_pattern("switch01-m2")
# The suffix -m2 should be stripped, returning "switch01"
assert result == "switch01" # suffix -m2 must be stripped
def test_returns_none_on_exception(self):
from netbox_librenms_plugin.views.base.librenms_sync_view import BaseLibreNMSSyncView
mock_settings_cls = MagicMock()
mock_settings_cls.objects.first.side_effect = Exception("DB error")
with patch("netbox_librenms_plugin.models.LibreNMSSettings", mock_settings_cls, create=True):
result = BaseLibreNMSSyncView._strip_vc_pattern("some-device")
assert result is None
class TestLibreNMSIdLegacyDetection:
"""Tests for librenms_id_is_legacy detection (lines 113-115)."""
def test_bare_int_cf_detected_as_legacy(self):
"""bare int CF → librenms_id_is_legacy = True."""
view = _make_view()
view.librenms_id = 42
view._librenms_lookup_device = MagicMock()
view._librenms_lookup_device.cf = {"librenms_id": 42}
obj = MagicMock()
obj.virtual_chassis = None
obj._meta = MagicMock()
obj._meta.model_name = "device"
obj.pk = 1
obj.serial = "SN"
obj.platform = None
view._librenms_api = MagicMock()
view._librenms_api.server_key = "default"
view._librenms_api.librenms_url = "https://x.example.com"
view.get_librenms_device_info = MagicMock(
return_value={
"found_in_librenms": True,
"librenms_device_details": {
"librenms_device_serial": "SN",
"librenms_device_hardware": "-",
"librenms_device_os": "-",
"librenms_device_version": "-",
"librenms_device_features": "-",
"librenms_device_location": "-",
"librenms_device_hardware_match": None,
"vc_inventory_serials": [],
},
"mismatched_device": False,
}
)
view.get_interface_context = MagicMock(return_value=None)
view.get_cable_context = MagicMock(return_value=None)
view.get_ip_context = MagicMock(return_value=None)
view.get_vlan_context = MagicMock(return_value=None)
with patch(
"netbox_librenms_plugin.views.base.librenms_sync_view.get_interface_name_field", return_value="ifName"
):
with patch(
"netbox_librenms_plugin.views.base.librenms_sync_view.BaseLibreNMSSyncView._build_all_server_mappings",
return_value=None,
):
with patch(
"netbox_librenms_plugin.views.base.librenms_sync_view.BaseLibreNMSSyncView._get_platform_info",
return_value={},
):
with patch("netbox_librenms_plugin.views.base.librenms_sync_view.AddToLIbreSNMPV1V2"):
with patch("netbox_librenms_plugin.views.base.librenms_sync_view.AddToLIbreSNMPV3"):
with patch("dcim.models.Manufacturer") as MockMfr:
MockMfr.objects.all.return_value.order_by.return_value = []
with patch(
"netbox_librenms_plugin.views.base.librenms_sync_view.LibreNMSAPIMixin.get_context_data",
return_value={},
):
ctx = view.get_context_data(MagicMock(), obj)
assert ctx.get("librenms_id_is_legacy") is True
class TestAbstractMethods:
"""Tests for abstract get_*_context methods (lines 349-376)."""
def test_get_interface_context_returns_none(self):
view = _make_view()
result = view.get_interface_context(MagicMock(), MagicMock())
assert result is None
def test_get_cable_context_returns_none(self):
view = _make_view()
result = view.get_cable_context(MagicMock(), MagicMock())
assert result is None
def test_get_ip_context_returns_none(self):
view = _make_view()
result = view.get_ip_context(MagicMock(), MagicMock())
assert result is None
def test_get_vlan_context_returns_none(self):
view = _make_view()
result = view.get_vlan_context(MagicMock(), MagicMock())
assert result is None
class TestGetVCInventorySerials:
"""Tests for _get_vc_inventory_serials (lines 412-452)."""
def test_no_inventory_returns_empty(self):
view = _make_view()
view.librenms_id = 42
view._librenms_api.get_device_inventory.return_value = (False, [])
obj = MagicMock()
obj.virtual_chassis = MagicMock()
obj.virtual_chassis.members.all.return_value = []
result = view._get_vc_inventory_serials(obj)
assert result == []
def test_chassis_components_matched(self):
view = _make_view()
view.librenms_id = 42
inventory = [
{
"entPhysicalClass": "chassis",
"entPhysicalSerialNum": "SN001",
"entPhysicalDescr": "Chassis",
"entPhysicalModelName": "C9300",
},
{
"entPhysicalClass": "module",
"entPhysicalSerialNum": "SN002",
"entPhysicalDescr": "Module",
"entPhysicalModelName": "",
},
]
view._librenms_api.get_device_inventory.return_value = (True, inventory)
member = MagicMock()
member.serial = "SN001"
obj = MagicMock()
obj.virtual_chassis = MagicMock()
obj.virtual_chassis.members.all.return_value = [member]
result = view._get_vc_inventory_serials(obj)
assert len(result) == 1
assert result[0]["serial"] == "SN001"
assert result[0]["assigned_member"] is member
def test_unassigned_serial_returns_none_member(self):
view = _make_view()
view.librenms_id = 42
inventory = [
{
"entPhysicalClass": "chassis",
"entPhysicalSerialNum": "UNKNOWN_SN",
"entPhysicalDescr": "Chassis",
"entPhysicalModelName": "MX480",
},
]
view._librenms_api.get_device_inventory.return_value = (True, inventory)
member = MagicMock()
member.serial = "SN001" # Different serial
obj = MagicMock()
obj.virtual_chassis = MagicMock()
obj.virtual_chassis.members.all.return_value = [member]
result = view._get_vc_inventory_serials(obj)
assert len(result) == 1
assert result[0]["assigned_member"] is None
def test_empty_serial_skipped(self):
view = _make_view()
view.librenms_id = 42
inventory = [
{
"entPhysicalClass": "chassis",
"entPhysicalSerialNum": "-",
"entPhysicalDescr": "Chassis",
"entPhysicalModelName": "",
},
]
view._librenms_api.get_device_inventory.return_value = (True, inventory)
obj = MagicMock()
obj.virtual_chassis = MagicMock()
obj.virtual_chassis.members.all.return_value = []
result = view._get_vc_inventory_serials(obj)
assert result == []
class TestGetPlatformInfo:
"""Tests for _get_platform_info (lines 463-502)."""
def test_no_os_returns_no_platform(self):
view = _make_view()
obj = MagicMock()
obj.platform = None
librenms_info = {
"librenms_device_details": {
"librenms_device_os": "-",
"librenms_device_version": "-",
}
}
with patch("dcim.models.Platform") as MockPlatform:
MockPlatform.DoesNotExist = type("DoesNotExist", (Exception,), {})
MockPlatform.objects.get.side_effect = MockPlatform.DoesNotExist()
result = view._get_platform_info(librenms_info, obj)
assert result["platform_exists"] is False
assert result["platform_name"] is None
def test_matching_platform_found(self):
view = _make_view()
obj = MagicMock()
mock_platform = MagicMock()
librenms_info = {
"librenms_device_details": {
"librenms_device_os": "ios",
"librenms_device_version": "16.9",
}
}
with patch("dcim.models.Platform") as MockPlatform:
MockPlatform.DoesNotExist = type("DoesNotExist", (Exception,), {})
MockPlatform.objects.get.return_value = mock_platform
result = view._get_platform_info(librenms_info, obj)
assert result["platform_exists"] is True
assert result["matching_platform"] is mock_platform
def test_platform_does_not_exist(self):
view = _make_view()
obj = MagicMock()
obj.platform = None
librenms_info = {
"librenms_device_details": {
"librenms_device_os": "eos",
"librenms_device_version": "4.28",
}
}
with patch("dcim.models.Platform") as MockPlatform:
MockPlatform.DoesNotExist = type("DoesNotExist", (Exception,), {})
MockPlatform.objects.get.side_effect = MockPlatform.DoesNotExist()
result = view._get_platform_info(librenms_info, obj)
assert result["platform_exists"] is False
assert result["matching_platform"] is None

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,980 @@
"""
Coverage tests for remaining gaps in views/sync/.
Targets:
- interfaces.py (SyncInterfacesView + DeleteNetBoxInterfacesView) - was 34%
- cables.py lines 147-149 (exception path in process_interface_sync)
- devices.py lines 77, 81-82 (port_association_mode, invalid poller_group)
- locations.py lines 26-28, 32-35, 44-49 (get_table, get_context_data, get_queryset)
- vlans.py lines 134-139 (grouped VLAN update/skip paths)
"""
from contextlib import contextmanager
from unittest.mock import MagicMock, patch
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_iv():
from netbox_librenms_plugin.views.sync.interfaces import SyncInterfacesView
v = object.__new__(SyncInterfacesView)
v._librenms_api = MagicMock()
v._librenms_api.server_key = "default"
v._post_server_key = "default"
v.request = MagicMock()
v.request.POST.get = lambda k, *a: None
v.object = MagicMock()
return v
def _make_dv():
from netbox_librenms_plugin.views.sync.interfaces import DeleteNetBoxInterfacesView
v = object.__new__(DeleteNetBoxInterfacesView)
v._librenms_api = MagicMock()
v.request = MagicMock()
return v
@contextmanager
def _pa():
"""Passthrough atomic: real context manager that does not suppress exceptions."""
yield
# ===========================================================================
# SyncInterfacesView.get_required_permissions_for_object_type
# ===========================================================================
class TestGetRequiredPermissionsForObjectType:
def test_device_returns_interface_perms(self):
from dcim.models import Interface
v = _make_iv()
perms = v.get_required_permissions_for_object_type("device")
assert any(a == "add" and m is Interface for a, m in perms)
assert any(a == "change" and m is Interface for a, m in perms)
def test_vm_returns_vminterface_perms(self):
from virtualization.models import VMInterface
v = _make_iv()
perms = v.get_required_permissions_for_object_type("virtualmachine")
assert any(a == "add" and m is VMInterface for a, m in perms)
def test_invalid_raises_http404(self):
import pytest
from django.http import Http404
v = _make_iv()
with pytest.raises(Http404):
v.get_required_permissions_for_object_type("rack")
# ===========================================================================
# SyncInterfacesView.get_object
# ===========================================================================
class TestSyncInterfacesGetObject:
def test_device_type(self):
v = _make_iv()
mock_obj = MagicMock()
with patch("netbox_librenms_plugin.views.sync.interfaces.get_object_or_404", return_value=mock_obj):
assert v.get_object("device", 1) is mock_obj
def test_vm_type(self):
v = _make_iv()
mock_obj = MagicMock()
with patch("netbox_librenms_plugin.views.sync.interfaces.get_object_or_404", return_value=mock_obj):
assert v.get_object("virtualmachine", 2) is mock_obj
def test_invalid_raises_http404(self):
import pytest
from django.http import Http404
v = _make_iv()
with pytest.raises(Http404):
v.get_object("rack", 1)
# ===========================================================================
# SyncInterfacesView.get_selected_interfaces
# ===========================================================================
class TestSyncGetSelectedInterfaces:
def test_empty_returns_none_and_error(self):
v = _make_iv()
req = MagicMock()
req.POST.getlist.return_value = []
with patch("netbox_librenms_plugin.views.sync.interfaces.messages") as mm:
result = v.get_selected_interfaces(req, "ifName")
assert result is None
mm.error.assert_called_once()
def test_with_values_returns_list(self):
v = _make_iv()
req = MagicMock()
req.POST.getlist.return_value = ["eth0", "eth1"]
assert v.get_selected_interfaces(req, "ifName") == ["eth0", "eth1"]
# ===========================================================================
# SyncInterfacesView.get_cached_ports_data
# ===========================================================================
class TestGetCachedPortsData:
def test_cache_miss_warns_and_returns_none(self):
v = _make_iv()
v.get_cache_key = MagicMock(return_value="k")
with patch("netbox_librenms_plugin.views.sync.interfaces.cache") as mc:
mc.get.return_value = None
with patch("netbox_librenms_plugin.views.sync.interfaces.messages") as mm:
result = v.get_cached_ports_data(MagicMock(), MagicMock())
assert result is None
mm.warning.assert_called_once()
def test_cache_hit_returns_ports(self):
v = _make_iv()
v.get_cache_key = MagicMock(return_value="k")
ports = [{"ifName": "eth0"}]
with patch("netbox_librenms_plugin.views.sync.interfaces.cache") as mc:
mc.get.return_value = {"ports": ports}
assert v.get_cached_ports_data(MagicMock(), MagicMock()) == ports
# ===========================================================================
# SyncInterfacesView.post
# ===========================================================================
class TestSyncInterfacesPost:
def _s(self):
v = _make_iv()
v.require_all_permissions = MagicMock(return_value=None)
v.get_vlan_groups_for_device = MagicMock(return_value=[])
v._build_vlan_lookup_maps = MagicMock(return_value={})
return v
def test_permission_denied(self):
v = self._s()
err = MagicMock()
v.require_all_permissions = MagicMock(return_value=err)
assert v.post(MagicMock(), "device", 1) is err
def test_no_selected_redirects(self):
from dcim.models import Device
v = self._s()
obj = MagicMock(spec=Device)
obj.pk = 1
v.get_object = MagicMock(return_value=obj)
v.get_selected_interfaces = MagicMock(return_value=None)
req = MagicMock()
req.POST.get = lambda k, *a: None
req.POST.getlist = lambda k: []
with patch("netbox_librenms_plugin.views.sync.interfaces.get_interface_name_field", return_value="ifName"):
with patch("netbox_librenms_plugin.views.sync.interfaces.reverse", return_value="/s/"):
with patch("netbox_librenms_plugin.views.sync.interfaces.redirect") as mr:
v.post(req, "device", 1)
mr.assert_called_once()
def test_no_ports_data_redirects(self):
from dcim.models import Device
v = self._s()
obj = MagicMock(spec=Device)
obj.pk = 1
v.get_object = MagicMock(return_value=obj)
v.get_selected_interfaces = MagicMock(return_value=["eth0"])
v.get_cached_ports_data = MagicMock(return_value=None)
req = MagicMock()
req.POST.get = lambda k, *a: None
req.POST.getlist = lambda k: []
with patch("netbox_librenms_plugin.views.sync.interfaces.get_interface_name_field", return_value="ifName"):
with patch("netbox_librenms_plugin.views.sync.interfaces.reverse", return_value="/s/"):
with patch("netbox_librenms_plugin.views.sync.interfaces.redirect") as mr:
v.post(req, "device", 1)
mr.assert_called_once()
def test_full_success_device(self):
from dcim.models import Device
v = self._s()
obj = MagicMock(spec=Device)
obj.pk = 1
v.get_object = MagicMock(return_value=obj)
v.get_selected_interfaces = MagicMock(return_value=["eth0"])
v.get_cached_ports_data = MagicMock(return_value=[{"ifName": "eth0"}])
v.sync_selected_interfaces = MagicMock()
req = MagicMock()
req.POST.get = lambda k, *a: "default" if k == "server_key" else None
req.POST.getlist = lambda k: []
with patch("netbox_librenms_plugin.views.sync.interfaces.get_interface_name_field", return_value="ifName"):
with patch("netbox_librenms_plugin.views.sync.interfaces.reverse", return_value="/s/"):
with patch("netbox_librenms_plugin.views.sync.interfaces.redirect") as mr:
with patch("netbox_librenms_plugin.views.sync.interfaces.messages") as mm:
v.post(req, "device", 1)
v.sync_selected_interfaces.assert_called_once()
mm.success.assert_called_once()
mr.assert_called_once()
def test_full_success_vm(self):
from virtualization.models import VirtualMachine
v = self._s()
obj = MagicMock(spec=VirtualMachine)
obj.pk = 2
v.get_object = MagicMock(return_value=obj)
v.get_selected_interfaces = MagicMock(return_value=["eth0"])
v.get_cached_ports_data = MagicMock(return_value=[{"ifName": "eth0"}])
v.sync_selected_interfaces = MagicMock()
req = MagicMock()
req.POST.get = lambda k, *a: None
req.POST.getlist = lambda k: []
with patch("netbox_librenms_plugin.views.sync.interfaces.get_interface_name_field", return_value="ifName"):
with patch("netbox_librenms_plugin.views.sync.interfaces.reverse", return_value="/s/"):
with patch("netbox_librenms_plugin.views.sync.interfaces.redirect"):
with patch("netbox_librenms_plugin.views.sync.interfaces.messages"):
v.post(req, "virtualmachine", 2)
v.sync_selected_interfaces.assert_called_once()
# ===========================================================================
# SyncInterfacesView.sync_selected_interfaces
# ===========================================================================
class TestSyncSelectedInterfaces:
def test_only_selected_processed(self):
from dcim.models import Device
v = _make_iv()
v.sync_interface = MagicMock()
obj = MagicMock(spec=Device)
ports = [{"ifName": "eth0"}, {"ifName": "eth1"}]
with patch("netbox_librenms_plugin.views.sync.interfaces.transaction"):
v.sync_selected_interfaces(obj, ["eth0"], ports, [], "ifName")
assert v.sync_interface.call_count == 1
assert v.sync_interface.call_args[0][1]["ifName"] == "eth0"
# ===========================================================================
# SyncInterfacesView.sync_interface
# ===========================================================================
class TestSyncInterface:
def _v(self):
v = _make_iv()
v.update_interface_attributes = MagicMock()
v._sync_interface_vlans = MagicMock()
v.get_netbox_interface_type = MagicMock(return_value="1000base-t")
v._lookup_maps = {}
return v
def test_device_no_vc_uses_obj(self):
from dcim.models import Device
v = self._v()
obj = MagicMock(spec=Device)
obj.id = 1
obj.virtual_chassis = None
iface = MagicMock()
with patch("netbox_librenms_plugin.views.sync.interfaces.Interface") as mc:
mc.objects.get_or_create.return_value = (iface, True)
v.sync_interface(obj, {"ifName": "eth0"}, [], "ifName")
mc.objects.get_or_create.assert_called_once_with(device=obj, name="eth0")
v.update_interface_attributes.assert_called_once()
def test_device_vc_target_in_valid_ids(self):
from dcim.models import Device
v = self._v()
obj = MagicMock(spec=Device)
obj.id = 1
vc = MagicMock()
vc.members.values_list.return_value = [1, 2, 3]
obj.virtual_chassis = vc
target = MagicMock()
target.id = 2
v.request.POST.get = lambda k, *a: "2" if k == "device_selection_eth0" else None
iface = MagicMock()
# Patch only Device.objects.get, not Device itself (isinstance must work)
with patch("netbox_librenms_plugin.views.sync.interfaces.Device.objects.get", return_value=target):
with patch("netbox_librenms_plugin.views.sync.interfaces.Interface") as mc:
mc.objects.get_or_create.return_value = (iface, True)
v.sync_interface(obj, {"ifName": "eth0"}, [], "ifName")
mc.objects.get_or_create.assert_called_once_with(device=target, name="eth0")
def test_device_vc_target_not_in_valid_ids_falls_back(self):
from dcim.models import Device
v = self._v()
obj = MagicMock(spec=Device)
obj.id = 1
vc = MagicMock()
vc.members.values_list.return_value = [1, 2, 3]
obj.virtual_chassis = vc
target = MagicMock()
target.id = 99
v.request.POST.get = lambda k, *a: "99" if k == "device_selection_eth0" else None
iface = MagicMock()
with patch("netbox_librenms_plugin.views.sync.interfaces.Device.objects.get", return_value=target):
with patch("netbox_librenms_plugin.views.sync.interfaces.Interface") as mc:
mc.objects.get_or_create.return_value = (iface, True)
v.sync_interface(obj, {"ifName": "eth0"}, [], "ifName")
mc.objects.get_or_create.assert_called_once_with(device=obj, name="eth0")
def test_device_no_vc_wrong_selection_falls_back(self):
from dcim.models import Device
v = self._v()
obj = MagicMock(spec=Device)
obj.id = 1
obj.virtual_chassis = None
target = MagicMock()
target.id = 99
v.request.POST.get = lambda k, *a: "99" if k == "device_selection_eth0" else None
iface = MagicMock()
with patch("netbox_librenms_plugin.views.sync.interfaces.Device.objects.get", return_value=target):
with patch("netbox_librenms_plugin.views.sync.interfaces.Interface") as mc:
mc.objects.get_or_create.return_value = (iface, True)
v.sync_interface(obj, {"ifName": "eth0"}, [], "ifName")
mc.objects.get_or_create.assert_called_once_with(device=obj, name="eth0")
def test_device_selection_does_not_exist_falls_back(self):
from dcim.models import Device
v = self._v()
obj = MagicMock(spec=Device)
obj.id = 1
obj.virtual_chassis = None
v.request.POST.get = lambda k, *a: "999" if k == "device_selection_eth0" else None
iface = MagicMock()
with patch(
"netbox_librenms_plugin.views.sync.interfaces.Device.objects.get",
side_effect=Device.DoesNotExist,
):
with patch("netbox_librenms_plugin.views.sync.interfaces.Interface") as mc:
mc.objects.get_or_create.return_value = (iface, True)
v.sync_interface(obj, {"ifName": "eth0"}, [], "ifName")
mc.objects.get_or_create.assert_called_once_with(device=obj, name="eth0")
def test_vm_uses_vminterface(self):
from virtualization.models import VirtualMachine
v = self._v()
obj = MagicMock(spec=VirtualMachine)
iface = MagicMock()
with patch("netbox_librenms_plugin.views.sync.interfaces.VMInterface") as mc:
mc.objects.get_or_create.return_value = (iface, True)
v.sync_interface(obj, {"ifName": "eth0"}, [], "ifName")
mc.objects.get_or_create.assert_called_once_with(virtual_machine=obj, name="eth0")
v.update_interface_attributes.assert_called_once()
def test_vlans_excluded_skips_sync(self):
from dcim.models import Device
v = self._v()
obj = MagicMock(spec=Device)
obj.id = 1
obj.virtual_chassis = None
iface = MagicMock()
with patch("netbox_librenms_plugin.views.sync.interfaces.Interface") as mc:
mc.objects.get_or_create.return_value = (iface, True)
v.sync_interface(obj, {"ifName": "eth0"}, ["vlans"], "ifName")
v._sync_interface_vlans.assert_not_called()
def test_vlans_not_excluded_calls_sync(self):
from dcim.models import Device
v = self._v()
obj = MagicMock(spec=Device)
obj.id = 1
obj.virtual_chassis = None
iface = MagicMock()
with patch("netbox_librenms_plugin.views.sync.interfaces.Interface") as mc:
mc.objects.get_or_create.return_value = (iface, True)
v.sync_interface(obj, {"ifName": "eth0"}, [], "ifName")
v._sync_interface_vlans.assert_called_once()
# ===========================================================================
# SyncInterfacesView.get_netbox_interface_type
# ===========================================================================
class TestGetNetboxInterfaceType:
def test_speed_mapping_found(self):
v = _make_iv()
mm = MagicMock()
mm.netbox_type = "1000base-t"
with patch("netbox_librenms_plugin.views.sync.interfaces.convert_speed_to_kbps", return_value=1000000):
with patch("netbox_librenms_plugin.views.sync.interfaces.InterfaceTypeMapping") as mc:
qs = MagicMock()
mc.objects.filter.return_value = qs
qs.filter.return_value.order_by.return_value.first.return_value = mm
result = v.get_netbox_interface_type({"ifType": "ethernetCsmacd", "ifSpeed": 1000000000})
assert result == "1000base-t"
def test_speed_not_found_falls_back_to_null(self):
v = _make_iv()
null_m = MagicMock()
null_m.netbox_type = "null-type"
with patch("netbox_librenms_plugin.views.sync.interfaces.convert_speed_to_kbps", return_value=1000000):
with patch("netbox_librenms_plugin.views.sync.interfaces.InterfaceTypeMapping") as mc:
qs = MagicMock()
mc.objects.filter.return_value = qs
qs.filter.return_value.order_by.return_value.first.return_value = None
qs.filter.return_value.first.return_value = null_m
result = v.get_netbox_interface_type({"ifType": "ethernetCsmacd", "ifSpeed": 1000000000})
assert result == "null-type"
def test_no_speed_uses_null_mapping(self):
v = _make_iv()
m = MagicMock()
m.netbox_type = "virtual"
with patch("netbox_librenms_plugin.views.sync.interfaces.convert_speed_to_kbps", return_value=None):
with patch("netbox_librenms_plugin.views.sync.interfaces.InterfaceTypeMapping") as mc:
qs = MagicMock()
mc.objects.filter.return_value = qs
qs.filter.return_value.first.return_value = m
result = v.get_netbox_interface_type({"ifType": "eth", "ifSpeed": None})
assert result == "virtual"
def test_no_mapping_returns_other(self):
v = _make_iv()
with patch("netbox_librenms_plugin.views.sync.interfaces.convert_speed_to_kbps", return_value=None):
with patch("netbox_librenms_plugin.views.sync.interfaces.InterfaceTypeMapping") as mc:
qs = MagicMock()
mc.objects.filter.return_value = qs
qs.filter.return_value.first.return_value = None
result = v.get_netbox_interface_type({"ifType": "unknown", "ifSpeed": None})
assert result == "other"
# ===========================================================================
# SyncInterfacesView._sync_interface_vlans
# ===========================================================================
class TestSyncInterfaceVlans:
def test_builds_vlan_group_map_for_untagged_and_tagged(self):
v = _make_iv()
v._lookup_maps = {}
v._update_interface_vlan_assignment = MagicMock()
iface = MagicMock()
port = {"untagged_vlan": 100, "tagged_vlans": [200]}
def pg(key, default=""):
return {"vlan_group_eth0_100": "5", "vlan_group_eth0_200": "5"}.get(key, default)
v.request.POST.get = pg
v._sync_interface_vlans(iface, port, "eth0")
args = v._update_interface_vlan_assignment.call_args[0]
assert args[2].get("100") == "5"
assert args[2].get("200") == "5"
def test_no_vlans_empty_map(self):
v = _make_iv()
v._lookup_maps = {}
v._update_interface_vlan_assignment = MagicMock()
v.request.POST.get = lambda k, *a: ""
v._sync_interface_vlans(MagicMock(), {"untagged_vlan": None, "tagged_vlans": []}, "eth0")
assert v._update_interface_vlan_assignment.call_args[0][2] == {}
def test_special_chars_in_name(self):
v = _make_iv()
v._lookup_maps = {}
v._update_interface_vlan_assignment = MagicMock()
v.request.POST.get = lambda k, *a: ""
v._sync_interface_vlans(MagicMock(), {"untagged_vlan": None, "tagged_vlans": []}, "eth0/1:2")
v._update_interface_vlan_assignment.assert_called_once()
# ===========================================================================
# DeleteNetBoxInterfacesView.get_required_permissions_for_object_type
# ===========================================================================
class TestDeleteGetRequiredPermissions:
def test_device_delete_interface(self):
from dcim.models import Interface
v = _make_dv()
perms = v.get_required_permissions_for_object_type("device")
assert any(a == "delete" and m is Interface for a, m in perms)
def test_vm_delete_vminterface(self):
from virtualization.models import VMInterface
v = _make_dv()
perms = v.get_required_permissions_for_object_type("virtualmachine")
assert any(a == "delete" and m is VMInterface for a, m in perms)
def test_invalid_raises_http404(self):
import pytest
from django.http import Http404
v = _make_dv()
with pytest.raises(Http404):
v.get_required_permissions_for_object_type("invalid")
# ===========================================================================
# DeleteNetBoxInterfacesView.post
# ===========================================================================
class TestDeleteNetBoxInterfacesPost:
def test_permission_denied(self):
v = _make_dv()
err = MagicMock()
v.require_all_permissions_json = MagicMock(return_value=err)
v.get_required_permissions_for_object_type = MagicMock(return_value=[])
req = MagicMock()
req.POST.getlist.return_value = ["1"]
assert v.post(req, "device", 1) is err
def test_invalid_object_type_400(self):
v = _make_dv()
v.require_all_permissions_json = MagicMock(return_value=None)
v.get_required_permissions_for_object_type = MagicMock(return_value=[])
req = MagicMock()
req.POST.getlist.return_value = ["1"]
resp = v.post(req, "rack", 1)
assert resp.status_code == 400
def test_no_ids_400(self):
v = _make_dv()
v.require_all_permissions_json = MagicMock(return_value=None)
v.get_required_permissions_for_object_type = MagicMock(return_value=[])
req = MagicMock()
req.POST.getlist.return_value = []
with patch("netbox_librenms_plugin.views.sync.interfaces.get_object_or_404"):
resp = v.post(req, "device", 1)
assert resp.status_code == 400
def test_device_successful_delete(self):
import json
v = _make_dv()
v.require_all_permissions_json = MagicMock(return_value=None)
v.get_required_permissions_for_object_type = MagicMock(return_value=[])
obj = MagicMock()
obj.id = 1
obj.virtual_chassis = None
iface = MagicMock()
iface.name = "eth0"
iface.device_id = 1
req = MagicMock()
req.POST.getlist.side_effect = lambda k: ["10"] if k == "interface_ids" else []
with patch("netbox_librenms_plugin.views.sync.interfaces.get_object_or_404", return_value=obj):
with patch("netbox_librenms_plugin.views.sync.interfaces.Interface") as mc:
mc.objects.get.return_value = iface
with patch("netbox_librenms_plugin.views.sync.interfaces.transaction") as mt:
mt.atomic = _pa
resp = v.post(req, "device", 1)
data = json.loads(resp.content)
assert data["deleted_count"] == 1
iface.delete.assert_called_once()
def test_device_wrong_device_id_error(self):
import json
v = _make_dv()
v.require_all_permissions_json = MagicMock(return_value=None)
v.get_required_permissions_for_object_type = MagicMock(return_value=[])
obj = MagicMock()
obj.id = 1
obj.virtual_chassis = None
iface = MagicMock()
iface.name = "eth0"
iface.device_id = 99
req = MagicMock()
req.POST.getlist.side_effect = lambda k: ["10"] if k == "interface_ids" else []
with patch("netbox_librenms_plugin.views.sync.interfaces.get_object_or_404", return_value=obj):
with patch("netbox_librenms_plugin.views.sync.interfaces.Interface") as mc:
mc.objects.get.return_value = iface
with patch("netbox_librenms_plugin.views.sync.interfaces.transaction") as mt:
mt.atomic = _pa
resp = v.post(req, "device", 1)
data = json.loads(resp.content)
assert data["deleted_count"] == 0
assert len(data["errors"]) > 0
def test_device_vc_interface_not_in_members(self):
import json
v = _make_dv()
v.require_all_permissions_json = MagicMock(return_value=None)
v.get_required_permissions_for_object_type = MagicMock(return_value=[])
obj = MagicMock()
obj.id = 1
vc = MagicMock()
m1 = MagicMock()
m1.id = 1
m2 = MagicMock()
m2.id = 2
vc.members.all.return_value = [m1, m2]
obj.virtual_chassis = vc
iface = MagicMock()
iface.name = "eth0"
iface.device_id = 99
req = MagicMock()
req.POST.getlist.side_effect = lambda k: ["10"] if k == "interface_ids" else []
with patch("netbox_librenms_plugin.views.sync.interfaces.get_object_or_404", return_value=obj):
with patch("netbox_librenms_plugin.views.sync.interfaces.Interface") as mc:
mc.objects.get.return_value = iface
with patch("netbox_librenms_plugin.views.sync.interfaces.transaction") as mt:
mt.atomic = _pa
resp = v.post(req, "device", 1)
data = json.loads(resp.content)
assert data["deleted_count"] == 0
assert len(data["errors"]) > 0
def test_device_vc_interface_in_members_deleted(self):
import json
v = _make_dv()
v.require_all_permissions_json = MagicMock(return_value=None)
v.get_required_permissions_for_object_type = MagicMock(return_value=[])
obj = MagicMock()
obj.id = 1
vc = MagicMock()
m1 = MagicMock()
m1.id = 1
m2 = MagicMock()
m2.id = 2
vc.members.all.return_value = [m1, m2]
obj.virtual_chassis = vc
iface = MagicMock()
iface.name = "eth0"
iface.device_id = 2
req = MagicMock()
req.POST.getlist.side_effect = lambda k: ["10"] if k == "interface_ids" else []
with patch("netbox_librenms_plugin.views.sync.interfaces.get_object_or_404", return_value=obj):
with patch("netbox_librenms_plugin.views.sync.interfaces.Interface") as mc:
mc.objects.get.return_value = iface
with patch("netbox_librenms_plugin.views.sync.interfaces.transaction") as mt:
mt.atomic = _pa
resp = v.post(req, "device", 1)
data = json.loads(resp.content)
assert data["deleted_count"] == 1
def test_vm_successful_delete(self):
import json
v = _make_dv()
v.require_all_permissions_json = MagicMock(return_value=None)
v.get_required_permissions_for_object_type = MagicMock(return_value=[])
obj = MagicMock()
obj.id = 5
iface = MagicMock()
iface.name = "eth0"
iface.virtual_machine_id = 5
req = MagicMock()
req.POST.getlist.side_effect = lambda k: ["20"] if k == "interface_ids" else []
with patch("netbox_librenms_plugin.views.sync.interfaces.get_object_or_404", return_value=obj):
with patch("netbox_librenms_plugin.views.sync.interfaces.VMInterface") as mc:
mc.objects.get.return_value = iface
with patch("netbox_librenms_plugin.views.sync.interfaces.transaction") as mt:
mt.atomic = _pa
resp = v.post(req, "virtualmachine", 5)
data = json.loads(resp.content)
assert data["deleted_count"] == 1
iface.delete.assert_called_once()
def test_vm_wrong_vm_error(self):
import json
v = _make_dv()
v.require_all_permissions_json = MagicMock(return_value=None)
v.get_required_permissions_for_object_type = MagicMock(return_value=[])
obj = MagicMock()
obj.id = 5
iface = MagicMock()
iface.name = "eth0"
iface.virtual_machine_id = 99
req = MagicMock()
req.POST.getlist.side_effect = lambda k: ["20"] if k == "interface_ids" else []
with patch("netbox_librenms_plugin.views.sync.interfaces.get_object_or_404", return_value=obj):
with patch("netbox_librenms_plugin.views.sync.interfaces.VMInterface") as mc:
mc.objects.get.return_value = iface
with patch("netbox_librenms_plugin.views.sync.interfaces.transaction") as mt:
mt.atomic = _pa
resp = v.post(req, "virtualmachine", 5)
data = json.loads(resp.content)
assert data["deleted_count"] == 0
assert len(data["errors"]) > 0
def test_interface_not_found_adds_error(self):
import json
from dcim.models import Interface
from virtualization.models import VMInterface as VMI
v = _make_dv()
v.require_all_permissions_json = MagicMock(return_value=None)
v.get_required_permissions_for_object_type = MagicMock(return_value=[])
obj = MagicMock()
obj.id = 1
obj.virtual_chassis = None
req = MagicMock()
req.POST.getlist.side_effect = lambda k: ["999"] if k == "interface_ids" else []
with patch("netbox_librenms_plugin.views.sync.interfaces.get_object_or_404", return_value=obj):
with patch("netbox_librenms_plugin.views.sync.interfaces.Interface") as mc:
mc.DoesNotExist = Interface.DoesNotExist
mc.objects.get.side_effect = Interface.DoesNotExist
with patch("netbox_librenms_plugin.views.sync.interfaces.VMInterface") as mvc:
mvc.DoesNotExist = VMI.DoesNotExist
with patch("netbox_librenms_plugin.views.sync.interfaces.transaction") as mt:
mt.atomic = _pa
resp = v.post(req, "device", 1)
data = json.loads(resp.content)
assert any("999" in e for e in data.get("errors", []))
def test_response_with_errors_includes_error_message(self):
import json
v = _make_dv()
v.require_all_permissions_json = MagicMock(return_value=None)
v.get_required_permissions_for_object_type = MagicMock(return_value=[])
obj = MagicMock()
obj.id = 1
obj.virtual_chassis = None
iface_ok = MagicMock()
iface_ok.name = "eth0"
iface_ok.device_id = 1
iface_bad = MagicMock()
iface_bad.name = "eth1"
iface_bad.device_id = 99
n = [0]
def get_se(**kw):
n[0] += 1
return iface_ok if n[0] == 1 else iface_bad
req = MagicMock()
req.POST.getlist.side_effect = lambda k: ["10", "20"] if k == "interface_ids" else []
with patch("netbox_librenms_plugin.views.sync.interfaces.get_object_or_404", return_value=obj):
with patch("netbox_librenms_plugin.views.sync.interfaces.Interface") as mc:
mc.objects.get.side_effect = get_se
with patch("netbox_librenms_plugin.views.sync.interfaces.transaction") as mt:
mt.atomic = _pa
resp = v.post(req, "device", 1)
data = json.loads(resp.content)
assert data["deleted_count"] == 1
assert "error(s)" in data["message"]
# ===========================================================================
# cables.py lines 147-149: exception path in process_interface_sync
# ===========================================================================
class TestCablesExceptionPath:
def test_exception_hits_147_to_149(self):
"""Lines 147-149: logger.exception + invalid.append when _passthrough_atomic used."""
from netbox_librenms_plugin.views.sync.cables import SyncCablesView
v = object.__new__(SyncCablesView)
v._librenms_api = MagicMock()
v.request = MagicMock()
def raise_err(iface, links):
raise RuntimeError("deliberate for coverage")
v.process_single_interface = raise_err
with patch("netbox_librenms_plugin.views.sync.cables.transaction") as mt:
mt.atomic = _pa
results = v.process_interface_sync([{"local_port_id": "eth_x"}], [])
assert "eth_x" in results["invalid"]
# ===========================================================================
# devices.py lines 77, 81-82: port_association_mode + invalid poller_group
# ===========================================================================
class TestDevicesFormValidEdgeCases:
def _v(self):
from netbox_librenms_plugin.views.sync.devices import AddDeviceToLibreNMSView
v = object.__new__(AddDeviceToLibreNMSView)
v._librenms_api = MagicMock()
v._librenms_api.add_device.return_value = (True, "Added")
v._librenms_api.server_key = "default"
v.request = MagicMock()
v.object = MagicMock()
v.object.get_absolute_url.return_value = "/d/"
return v
def test_port_association_mode_line_77(self):
"""Line 77: device_data[port_association_mode] set when truthy."""
v = self._v()
f = MagicMock()
f.cleaned_data = {"hostname": "h", "force_add": False, "port_association_mode": 2, "community": "pub"}
with patch("netbox_librenms_plugin.views.sync.devices.messages"):
with patch("netbox_librenms_plugin.views.sync.devices.redirect"):
v.form_valid(f, snmp_version="v2c")
dd = v._librenms_api.add_device.call_args[0][0]
assert dd["port_association_mode"] == 2
def test_invalid_poller_group_lines_81_82(self):
"""Lines 81-82: except (ValueError, TypeError) silently catches invalid int."""
v = self._v()
f = MagicMock()
f.cleaned_data = {"hostname": "h", "force_add": False, "poller_group": "bad-int", "community": "pub"}
with patch("netbox_librenms_plugin.views.sync.devices.messages"):
with patch("netbox_librenms_plugin.views.sync.devices.redirect"):
v.form_valid(f, snmp_version="v2c")
dd = v._librenms_api.add_device.call_args[0][0]
assert "poller_group" not in dd
# ===========================================================================
# locations.py lines 26-28, 32-35, 44-49
# ===========================================================================
class TestSyncSiteLocationViewGetTable:
def test_get_table_configures_table(self):
"""Lines 26-28: get_table calls super().get_table then table.configure(request)."""
import django_tables2
from netbox_librenms_plugin.views.sync.locations import SyncSiteLocationView
v = object.__new__(SyncSiteLocationView)
v.request = MagicMock()
mt = MagicMock()
with patch.object(django_tables2.SingleTableView, "get_table", return_value=mt):
result = v.get_table()
mt.configure.assert_called_once_with(v.request)
assert result is mt
class TestSyncSiteLocationViewGetContextData:
def test_adds_filter_form(self):
"""Lines 32-35: adds filter_form to context."""
import django_tables2
from netbox_librenms_plugin.views.sync.locations import SyncSiteLocationView
v = object.__new__(SyncSiteLocationView)
v.request = MagicMock()
v.request.GET = {}
mf = MagicMock()
mf.return_value.form = MagicMock()
v.filterset = mf
with patch.object(django_tables2.SingleTableView, "get_context_data", return_value={}):
with patch.object(type(v), "get_queryset", return_value=[]):
ctx = v.get_context_data()
assert "filter_form" in ctx
class TestSyncSiteLocationViewGetQuerysetSuccess:
def test_returns_sync_data(self):
"""Lines 44, 49: build sync_data list and return it."""
from netbox_librenms_plugin.views.sync.locations import SyncSiteLocationView
v = object.__new__(SyncSiteLocationView)
v.request = MagicMock()
v.request.GET = {}
v.filterset = None
sd = MagicMock()
with patch("netbox_librenms_plugin.views.sync.locations.Site") as ms:
ms.objects.all.return_value = [MagicMock()]
with patch.object(v, "get_librenms_locations", return_value=(True, [{"location": "T"}])):
with patch.object(v, "create_sync_data", return_value=sd):
result = v.get_queryset()
assert result == [sd]
def test_filterset_branch(self):
"""Lines 46-47: filterset branch when request.GET is truthy."""
from netbox_librenms_plugin.views.sync.locations import SyncSiteLocationView
v = object.__new__(SyncSiteLocationView)
v.request = MagicMock()
v.request.GET = {"name": "x"}
mf = MagicMock()
filtered = [MagicMock()]
mf.return_value.qs = filtered
v.filterset = mf
with patch("netbox_librenms_plugin.views.sync.locations.Site") as ms:
ms.objects.all.return_value = [MagicMock()]
with patch.object(v, "get_librenms_locations", return_value=(True, [{"location": "T"}])):
with patch.object(v, "create_sync_data", return_value=MagicMock()):
result = v.get_queryset()
assert result is filtered
# ===========================================================================
# vlans.py lines 134-139: grouped VLAN update/skip within if row_vlan_group: block
# ===========================================================================
class TestVlansGroupedUpdateAndSkip:
def _v(self):
from netbox_librenms_plugin.views.sync.vlans import SyncVLANsView
v = object.__new__(SyncVLANsView)
v._librenms_api = MagicMock()
v._librenms_api.server_key = "default"
v._post_server_key = "default"
v.get_cache_key = MagicMock(return_value="k")
v._redirect = MagicMock(return_value=MagicMock())
req = MagicMock()
req.POST.getlist = lambda k: ["100"] if k == "select" else []
req.POST.get = lambda key, default="": "3" if key == "vlan_group_100" else default
v.request = req
return v
def test_grouped_update_path_lines_134_to_137(self):
"""elif vlan.name != librenms_name: update triggered."""
from ipam.models import VLANGroup
v = self._v()
mg = MagicMock()
mv = MagicMock()
mv.name = "OldName"
with patch("netbox_librenms_plugin.views.sync.vlans.cache") as mc:
mc.get.return_value = [{"vlan_vlan": 100, "vlan_name": "NewName"}]
with patch("netbox_librenms_plugin.views.sync.vlans.VLANGroup") as mvg:
mvg.DoesNotExist = VLANGroup.DoesNotExist
mvg.objects.get.return_value = mg
with patch("netbox_librenms_plugin.views.sync.vlans.VLAN") as mvl:
mvl.objects.get_or_create.return_value = (mv, False)
with patch("netbox_librenms_plugin.views.sync.vlans.transaction"):
with patch("netbox_librenms_plugin.views.sync.vlans.messages") as mm:
v._handle_create_vlans(v.request, MagicMock(), "device", 1)
mv.save.assert_called_once()
assert mv.name == "NewName"
assert "updated" in str(mm.success.call_args)
def test_grouped_skip_path_lines_138_to_139(self):
"""else: skipped_count when name unchanged."""
from ipam.models import VLANGroup
v = self._v()
mg = MagicMock()
mv = MagicMock()
mv.name = "Same"
with patch("netbox_librenms_plugin.views.sync.vlans.cache") as mc:
mc.get.return_value = [{"vlan_vlan": 100, "vlan_name": "Same"}]
with patch("netbox_librenms_plugin.views.sync.vlans.VLANGroup") as mvg:
mvg.DoesNotExist = VLANGroup.DoesNotExist
mvg.objects.get.return_value = mg
with patch("netbox_librenms_plugin.views.sync.vlans.VLAN") as mvl:
mvl.objects.get_or_create.return_value = (mv, False)
with patch("netbox_librenms_plugin.views.sync.vlans.transaction"):
with patch("netbox_librenms_plugin.views.sync.vlans.messages") as mm:
v._handle_create_vlans(v.request, MagicMock(), "device", 1)
mv.save.assert_not_called()
assert "unchanged" in str(mm.success.call_args)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,551 @@
"""Coverage tests for utils.py missing lines."""
from unittest.mock import MagicMock, patch
class TestConvertSpeedToKbps:
"""Boundary and type tests for convert_speed_to_kbps."""
def test_none_returns_none(self):
from netbox_librenms_plugin.utils import convert_speed_to_kbps
assert convert_speed_to_kbps(None) is None
def test_zero_returns_zero(self):
from netbox_librenms_plugin.utils import convert_speed_to_kbps
assert convert_speed_to_kbps(0) == 0
def test_sub_kbps_rounds_down_to_zero(self):
from netbox_librenms_plugin.utils import convert_speed_to_kbps
assert convert_speed_to_kbps(1) == 0
assert convert_speed_to_kbps(999) == 0
def test_exact_kbps_boundary(self):
from netbox_librenms_plugin.utils import convert_speed_to_kbps
assert convert_speed_to_kbps(1000) == 1
def test_1gbps(self):
from netbox_librenms_plugin.utils import convert_speed_to_kbps
assert convert_speed_to_kbps(1_000_000_000) == 1_000_000
def test_string_input_raises_type_error(self):
import pytest
from netbox_librenms_plugin.utils import convert_speed_to_kbps
with pytest.raises(TypeError):
convert_speed_to_kbps("1000000")
class TestGetVirtualChassisMemberException:
"""Tests for get_virtual_chassis_member exception path (lines 76-77)."""
def test_exception_returns_original_device(self):
"""When ObjectDoesNotExist raised, return original device."""
from django.core.exceptions import ObjectDoesNotExist
from netbox_librenms_plugin.utils import get_virtual_chassis_member
device = MagicMock()
device.virtual_chassis = MagicMock()
device.virtual_chassis.members.get.side_effect = ObjectDoesNotExist("not found")
result = get_virtual_chassis_member(device, "Ethernet1")
assert result is device
def test_no_virtual_chassis_returns_device(self):
from netbox_librenms_plugin.utils import get_virtual_chassis_member
device = MagicMock()
device.virtual_chassis = None
result = get_virtual_chassis_member(device, "Ethernet1")
assert result is device
def test_port_name_no_digit_returns_device(self):
from netbox_librenms_plugin.utils import get_virtual_chassis_member
device = MagicMock()
device.virtual_chassis = MagicMock()
# Port name with no leading digit after alpha chars → no match
result = get_virtual_chassis_member(device, "Management")
assert result is device
class TestGetLibreNMSSyncDeviceServerKey:
"""Tests for get_librenms_sync_device with server_key (lines 113-125)."""
def test_returns_member_with_dict_cf_for_server_key(self):
"""Priority 1: member with dict CF matching server_key."""
from netbox_librenms_plugin.utils import get_librenms_sync_device
device = MagicMock()
vc = MagicMock()
device.virtual_chassis = vc
member1 = MagicMock()
member1.cf = {"librenms_id": {"default": 42}}
member2 = MagicMock()
member2.cf = {"librenms_id": None}
vc.members.all.return_value = [member1, member2]
result = get_librenms_sync_device(device, server_key="default")
assert result is member1
def test_falls_back_to_get_librenms_device_id_when_no_dict(self):
"""Priority 2 legacy: falls back to get_librenms_device_id."""
from netbox_librenms_plugin.utils import get_librenms_sync_device
device = MagicMock()
vc = MagicMock()
device.virtual_chassis = vc
member = MagicMock()
member.cf = {"librenms_id": None}
member.primary_ip = MagicMock()
vc.members.all.return_value = [member]
vc.master = None
with patch("netbox_librenms_plugin.utils.get_librenms_device_id") as mock_get_id:
mock_get_id.return_value = 99
result = get_librenms_sync_device(device, server_key="default")
assert result is member
def test_server_key_none_matches_any_dict_member(self):
"""server_key=None: matches any member with any librenms_id in dict."""
from netbox_librenms_plugin.utils import get_librenms_sync_device
device = MagicMock()
vc = MagicMock()
device.virtual_chassis = vc
member_with_id = MagicMock()
member_with_id.cf = {"librenms_id": {"primary": 10}}
member_without_id = MagicMock()
member_without_id.cf = {"librenms_id": None}
vc.members.all.return_value = [member_without_id, member_with_id]
result = get_librenms_sync_device(device, server_key=None)
assert result is member_with_id
def test_server_key_none_matches_legacy_cf(self):
"""server_key=None: matches member with legacy bare int librenms_id."""
from netbox_librenms_plugin.utils import get_librenms_sync_device
device = MagicMock()
vc = MagicMock()
device.virtual_chassis = vc
member = MagicMock()
member.cf = {"librenms_id": 42} # legacy bare int
vc.members.all.return_value = [member]
result = get_librenms_sync_device(device, server_key=None)
assert result is member
class TestGetLibreNMSSyncDeviceLegacyInt:
"""Tests for get_librenms_sync_device legacy int CF (lines 132-133)."""
def test_legacy_int_cf_with_server_key_uses_get_id(self):
"""server_key set, raw_cf is legacy int → doesn't match dict path, falls back."""
from netbox_librenms_plugin.utils import get_librenms_sync_device
device = MagicMock()
vc = MagicMock()
device.virtual_chassis = vc
member = MagicMock()
member.cf = {"librenms_id": 55} # legacy int, not dict
vc.members.all.return_value = [member]
vc.master = None
with patch("netbox_librenms_plugin.utils.get_librenms_device_id") as mock_get_id:
mock_get_id.return_value = 55
result = get_librenms_sync_device(device, server_key="default")
assert result is member
class TestGetLibreNMSSyncDeviceFallbacks:
"""Tests for get_librenms_sync_device fallback paths (lines 138-150)."""
def test_falls_back_to_master_with_primary_ip(self):
"""When no member has librenms_id, uses master with primary IP."""
from netbox_librenms_plugin.utils import get_librenms_sync_device
device = MagicMock()
vc = MagicMock()
device.virtual_chassis = vc
member = MagicMock()
member.cf = {"librenms_id": None}
master = MagicMock()
master.primary_ip = MagicMock()
vc.master = master
vc.members.all.return_value = [member]
with patch("netbox_librenms_plugin.utils.get_librenms_device_id", return_value=None):
result = get_librenms_sync_device(device, server_key="default")
assert result is master
def test_falls_back_to_any_member_with_primary_ip(self):
"""When no master, falls back to any member with primary IP."""
from netbox_librenms_plugin.utils import get_librenms_sync_device
device = MagicMock()
vc = MagicMock()
device.virtual_chassis = vc
member_no_ip = MagicMock()
member_no_ip.cf = {"librenms_id": None}
member_no_ip.primary_ip = None
member_with_ip = MagicMock()
member_with_ip.cf = {"librenms_id": None}
member_with_ip.primary_ip = MagicMock()
vc.master = None
vc.members.all.return_value = [member_no_ip, member_with_ip]
with patch("netbox_librenms_plugin.utils.get_librenms_device_id", return_value=None):
result = get_librenms_sync_device(device, server_key="default")
assert result is member_with_ip
def test_falls_back_to_lowest_vc_position(self):
"""Fallback to member with lowest vc_position when no IPs."""
from netbox_librenms_plugin.utils import get_librenms_sync_device
device = MagicMock()
vc = MagicMock()
device.virtual_chassis = vc
m1 = MagicMock()
m1.cf = {"librenms_id": None}
m1.primary_ip = None
m1.vc_position = 3
m2 = MagicMock()
m2.cf = {"librenms_id": None}
m2.primary_ip = None
m2.vc_position = 1
vc.master = None
vc.members.all.return_value = [m1, m2]
with patch("netbox_librenms_plugin.utils.get_librenms_device_id", return_value=None):
result = get_librenms_sync_device(device, server_key="default")
assert result is m2
class TestGetTablePaginateCountValueError:
"""Tests for get_table_paginate_count ValueError path (lines 169-170)."""
def test_invalid_per_page_falls_back_to_default(self):
from netbox_librenms_plugin.utils import get_table_paginate_count
request = MagicMock()
request.GET = {"table_per_page": "not_a_number"}
with patch("netbox_librenms_plugin.utils.get_config"):
with patch("netbox_librenms_plugin.utils.netbox_get_paginate_count") as mock_paginate:
mock_paginate.return_value = 50
result = get_table_paginate_count(request, "table_")
assert result == 50
class TestGetUserPrefNoConfig:
"""Tests for get_user_pref when user has no config (line 179)."""
def test_returns_default_when_no_config_attr(self):
from netbox_librenms_plugin.utils import get_user_pref
request = MagicMock(spec=["user"])
request.user = MagicMock(spec=["has_perm"]) # No 'config' attr
result = get_user_pref(request, "some.pref", default="fallback")
assert result == "fallback"
def test_returns_none_when_no_user(self):
from netbox_librenms_plugin.utils import get_user_pref
request = MagicMock(spec=[]) # No 'user' attr
result = get_user_pref(request, "some.pref")
assert result is None
class TestSaveUserPrefExceptions:
"""Tests for save_user_pref TypeError/ValueError exceptions (lines 187-188)."""
def test_type_error_is_swallowed(self):
from netbox_librenms_plugin.utils import save_user_pref
request = MagicMock()
request.user = MagicMock()
request.user.config.set.side_effect = TypeError("bad type")
# Should not raise
save_user_pref(request, "some.pref", "value")
def test_value_error_is_swallowed(self):
from netbox_librenms_plugin.utils import save_user_pref
request = MagicMock()
request.user = MagicMock()
request.user.config.set.side_effect = ValueError("bad value")
save_user_pref(request, "some.pref", "value")
class TestMatchLibrenmsHardwareImportError:
"""Tests for DeviceTypeMapping ImportError guard (line 242)."""
def test_no_hardware_returns_no_match(self):
"""Empty hardware string returns no match."""
from netbox_librenms_plugin.utils import match_librenms_hardware_to_device_type
result = match_librenms_hardware_to_device_type("")
assert result["matched"] is False
def test_dash_hardware_returns_no_match(self):
"""'-' hardware returns no match."""
from netbox_librenms_plugin.utils import match_librenms_hardware_to_device_type
result = match_librenms_hardware_to_device_type("-")
assert result["matched"] is False
class TestMatchLibrenmsHardwareDeviceTypeMappingPaths:
"""Tests for DeviceTypeMapping paths (lines 251-261)."""
def test_device_type_mapping_found(self):
"""DeviceTypeMapping.objects.get returns match → return mapping result."""
from netbox_librenms_plugin.utils import match_librenms_hardware_to_device_type
mock_device_type = MagicMock()
mock_mapping = MagicMock()
mock_mapping.netbox_device_type = mock_device_type
DoesNotExist = type("DoesNotExist", (Exception,), {})
MultipleObjectsReturned = type("MultipleObjectsReturned", (Exception,), {})
mock_dtm_class = MagicMock()
mock_dtm_class.DoesNotExist = DoesNotExist
mock_dtm_class.MultipleObjectsReturned = MultipleObjectsReturned
mock_dtm_class.objects.get.return_value = mock_mapping
with patch("netbox_librenms_plugin.models.DeviceTypeMapping", mock_dtm_class, create=True):
result = match_librenms_hardware_to_device_type("C9300-48P")
assert result["matched"] is True
assert result["device_type"] is mock_device_type
assert result["match_type"] == "mapping"
def test_device_type_mapping_multiple_returns_logs_warning(self):
"""DeviceTypeMapping.MultipleObjectsReturned → logs warning and skips mapping."""
from netbox_librenms_plugin.utils import match_librenms_hardware_to_device_type
DoesNotExist = type("DoesNotExist", (Exception,), {})
MultipleObjectsReturned = type("MultipleObjectsReturned", (Exception,), {})
mock_dtm_class = MagicMock()
mock_dtm_class.DoesNotExist = DoesNotExist
mock_dtm_class.MultipleObjectsReturned = MultipleObjectsReturned
mock_dtm_class.objects.get.side_effect = MultipleObjectsReturned("multiple")
dt_DoesNotExist = type("DoesNotExist", (Exception,), {})
dt_MultipleObjectsReturned = type("MultipleObjectsReturned", (Exception,), {})
with patch("netbox_librenms_plugin.models.DeviceTypeMapping", mock_dtm_class, create=True):
with patch("dcim.models.DeviceType") as MockDT:
MockDT.DoesNotExist = dt_DoesNotExist
MockDT.MultipleObjectsReturned = dt_MultipleObjectsReturned
MockDT.objects.get.side_effect = dt_DoesNotExist("no match")
result = match_librenms_hardware_to_device_type("Ambiguous Hardware")
assert result is None # multiple DeviceTypeMapping matches returns None (ambiguous)
class TestMatchLibrenmsHardwareDeviceTypeMultipleReturned:
"""Tests for DeviceType MultipleObjectsReturned — ambiguity surfaces as None."""
def test_part_number_multiple_returns_none(self):
"""DeviceType.MultipleObjectsReturned for part_number → return None (not silently pick first)."""
from netbox_librenms_plugin.utils import match_librenms_hardware_to_device_type
DoesNotExist = type("DoesNotExist", (Exception,), {})
MultipleObjectsReturned = type("MultipleObjectsReturned", (Exception,), {})
dtm_DoesNotExist = type("DoesNotExist", (Exception,), {})
mock_dtm = MagicMock()
mock_dtm.DoesNotExist = dtm_DoesNotExist
mock_dtm.MultipleObjectsReturned = type("MultipleObjectsReturned", (Exception,), {})
mock_dtm.objects.get.side_effect = dtm_DoesNotExist()
with patch("netbox_librenms_plugin.models.DeviceTypeMapping", mock_dtm, create=True):
with patch("dcim.models.DeviceType") as MockDT:
MockDT.DoesNotExist = DoesNotExist
MockDT.MultipleObjectsReturned = MultipleObjectsReturned
MockDT.objects.get.side_effect = MultipleObjectsReturned("multiple")
result = match_librenms_hardware_to_device_type("C9300")
assert result is None
def test_model_multiple_returns_none(self):
"""DeviceType.MultipleObjectsReturned for model → return None (not silently pick first)."""
from netbox_librenms_plugin.utils import match_librenms_hardware_to_device_type
DoesNotExist = type("DoesNotExist", (Exception,), {})
MultipleObjectsReturned = type("MultipleObjectsReturned", (Exception,), {})
def get_side_effect(**kwargs):
if "part_number__iexact" in kwargs:
raise DoesNotExist("no part number")
raise MultipleObjectsReturned("multiple models")
dtm_DoesNotExist = type("DoesNotExist", (Exception,), {})
mock_dtm = MagicMock()
mock_dtm.DoesNotExist = dtm_DoesNotExist
mock_dtm.MultipleObjectsReturned = type("MultipleObjectsReturned", (Exception,), {})
mock_dtm.objects.get.side_effect = dtm_DoesNotExist()
with patch("netbox_librenms_plugin.models.DeviceTypeMapping", mock_dtm, create=True):
with patch("dcim.models.DeviceType") as MockDT:
MockDT.DoesNotExist = DoesNotExist
MockDT.MultipleObjectsReturned = MultipleObjectsReturned
MockDT.objects.get.side_effect = get_side_effect
result = match_librenms_hardware_to_device_type("SomeModel")
assert result is None
class TestFindMatchingSiteMultipleReturned:
"""Tests for find_matching_site MultipleObjectsReturned (lines 325-327)."""
def test_multiple_objects_returned_uses_first(self):
from netbox_librenms_plugin.utils import find_matching_site
mock_site = MagicMock()
Site_DoesNotExist = type("DoesNotExist", (Exception,), {})
Site_MultipleObjectsReturned = type("MultipleObjectsReturned", (Exception,), {})
with patch("dcim.models.Site") as MockSite:
MockSite.DoesNotExist = Site_DoesNotExist
MockSite.MultipleObjectsReturned = Site_MultipleObjectsReturned
MockSite.objects.get.side_effect = Site_MultipleObjectsReturned("multiple")
MockSite.objects.filter.return_value.first.return_value = mock_site
result = find_matching_site("NYC")
assert result["found"] is True
assert result["site"] is mock_site
class TestFindMatchingPlatformMultipleReturned:
"""Tests for find_matching_platform MultipleObjectsReturned (lines 358-360)."""
def test_multiple_objects_returned_uses_first(self):
from netbox_librenms_plugin.utils import find_matching_platform
mock_platform = MagicMock()
Platform_DoesNotExist = type("DoesNotExist", (Exception,), {})
Platform_MultipleObjectsReturned = type("MultipleObjectsReturned", (Exception,), {})
with patch("dcim.models.Platform") as MockPlatform:
MockPlatform.DoesNotExist = Platform_DoesNotExist
MockPlatform.MultipleObjectsReturned = Platform_MultipleObjectsReturned
MockPlatform.objects.get.side_effect = Platform_MultipleObjectsReturned("multiple")
MockPlatform.objects.filter.return_value.first.return_value = mock_platform
result = find_matching_platform("ios")
assert result["found"] is True
assert result["platform"] is mock_platform
class TestGetMissingVlanWarning:
"""Tests for get_missing_vlan_warning when vid in missing_vlans (lines 462-467)."""
def test_vid_in_missing_vlans_returns_warning_html(self):
from netbox_librenms_plugin.utils import get_missing_vlan_warning
result = get_missing_vlan_warning(100, [100, 200])
assert "mdi-alert" in result
assert "text-danger" in result
def test_vid_not_in_missing_vlans_returns_empty_string(self):
from netbox_librenms_plugin.utils import get_missing_vlan_warning
result = get_missing_vlan_warning(999, [100, 200])
assert result == ""
class TestGetLibreNMSDeviceIdStringNormalization:
"""Tests for get_librenms_device_id string normalization (lines 557-558)."""
def test_string_id_normalized_to_int_and_saved(self):
"""String stored as librenms_id is normalized to int and saved."""
from netbox_librenms_plugin.utils import get_librenms_device_id
obj = MagicMock()
obj.cf = {"librenms_id": "42"}
obj.custom_field_data = {"librenms_id": "42"}
result = get_librenms_device_id(obj, "default", auto_save=True)
assert result == 42
# Should save to normalize
obj.save.assert_called_once()
def test_string_id_returned_without_save_when_auto_save_false(self):
"""String normalized but not saved when auto_save=False."""
from netbox_librenms_plugin.utils import get_librenms_device_id
obj = MagicMock()
obj.cf = {"librenms_id": "99"}
obj.custom_field_data = {"librenms_id": "99"}
result = get_librenms_device_id(obj, "default", auto_save=False)
assert result == 99
obj.save.assert_not_called()
def test_dict_with_string_value_normalized(self):
"""Dict entry with string value is normalized to int."""
from netbox_librenms_plugin.utils import get_librenms_device_id
obj = MagicMock()
obj.cf = {"librenms_id": {"default": "77"}}
obj.custom_field_data = {"librenms_id": {"default": "77"}}
result = get_librenms_device_id(obj, "default", auto_save=True)
assert result == 77
obj.save.assert_called_once()
def test_invalid_string_returns_none(self):
"""Non-digit string in librenms_id returns None."""
from netbox_librenms_plugin.utils import get_librenms_device_id
obj = MagicMock()
obj.cf = {"librenms_id": "not-a-number"}
obj.custom_field_data = {"librenms_id": "not-a-number"}
result = get_librenms_device_id(obj, "default")
assert result is None
class TestFindByLibreNMSIdNoneGuard:
"""Verify find_by_librenms_id returns None for None input without querying the DB."""
def test_none_id_returns_none_without_query(self):
"""find_by_librenms_id(None, ...) must return None without hitting the DB."""
from netbox_librenms_plugin.utils import find_by_librenms_id
model = MagicMock()
result = find_by_librenms_id(model, None, server_key="default")
assert result is None
model.objects.filter.assert_not_called()

View File

@@ -0,0 +1,294 @@
"""Coverage tests for virtual_chassis.py lines 431 and 435."""
from contextlib import contextmanager
from unittest.mock import MagicMock, patch
def _make_master_device(serial="MASTER001"):
"""Build a mock master Device for VC creation tests."""
master = MagicMock()
master.name = "switch-master"
master.serial = serial
master.pk = 1
master.rack = None
master.location = None
master.device_type = MagicMock()
master.role = MagicMock()
master.site = MagicMock()
master.platform = MagicMock()
return master
class TestCreateVirtualChassisWithMembersPositionConflict:
"""Tests specifically for lines 431 and 435 - position conflict resolution."""
@patch("netbox_librenms_plugin.import_utils.virtual_chassis.transaction")
@patch("netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern")
@patch("netbox_librenms_plugin.import_utils.virtual_chassis.VirtualChassis")
@patch("netbox_librenms_plugin.import_utils.virtual_chassis.Device")
def test_line_431_position_conflict_sets_discovered_pos_to_none(
self, mock_Device, mock_VirtualChassis, mock_load_pattern, mock_transaction
):
"""
Line 431: discovered_pos = None when position already in used_positions.
Scenario: master is at position 1 (used_positions = {1}).
First member takes position 2. Second member also claims position 2
→ discovered_pos set to None → falls back to sequential (position 3).
"""
from netbox_librenms_plugin.import_utils.virtual_chassis import create_virtual_chassis_with_members
# Make transaction.atomic() a no-op context manager
@contextmanager
def noop_atomic():
yield
mock_transaction.atomic = noop_atomic
mock_load_pattern.return_value = "-M{position}"
master = _make_master_device("MASTER001")
vc_mock = MagicMock()
vc_mock.members.count.return_value = 3
mock_VirtualChassis.objects.create.return_value = vc_mock
# Device.objects.filter(...).exists() → False (no conflicts)
mock_filter = MagicMock()
mock_filter.exists.return_value = False
mock_filter.exclude.return_value = mock_filter
mock_Device.objects.filter.return_value = mock_filter
mock_Device.objects.create.return_value = MagicMock()
# Members: first at position 2, second ALSO at position 2 (conflict)
members_info = [
{"serial": "SN002", "position": 2, "name": "Member2"},
{"serial": "SN003", "position": 2, "name": "Member3-conflict"}, # triggers line 431
]
libre_device = {"device_id": 99}
create_virtual_chassis_with_members(master, members_info, libre_device)
# VC should be created
mock_VirtualChassis.objects.create.assert_called_once()
# Two Device.objects.create calls for the two non-master members
create_calls = mock_Device.objects.create.call_args_list
assert len(create_calls) == 2
# Map serial -> vc_position for precise identity assertions
serial_to_pos = {c.kwargs.get("serial"): c.kwargs.get("vc_position") for c in create_calls}
# First member (SN002) takes its explicit position 2
assert serial_to_pos.get("SN002") == 2
# Second member (SN003) conflicts at 2, falls back to 3
assert serial_to_pos.get("SN003") == 3
@patch("netbox_librenms_plugin.import_utils.virtual_chassis.transaction")
@patch("netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern")
@patch("netbox_librenms_plugin.import_utils.virtual_chassis.VirtualChassis")
@patch("netbox_librenms_plugin.import_utils.virtual_chassis.Device")
def test_line_435_while_loop_skips_taken_slots(
self, mock_Device, mock_VirtualChassis, mock_load_pattern, mock_transaction
):
"""Line 435: position += 1 in while loop when sequential slot is taken."""
from netbox_librenms_plugin.import_utils.virtual_chassis import create_virtual_chassis_with_members
@contextmanager
def noop_atomic():
yield
mock_transaction.atomic = noop_atomic
mock_load_pattern.return_value = "-M{position}"
master = _make_master_device("MASTER001")
vc_mock = MagicMock()
vc_mock.members.count.return_value = 3
mock_VirtualChassis.objects.create.return_value = vc_mock
mock_filter = MagicMock()
mock_filter.exists.return_value = False
mock_filter.exclude.return_value = mock_filter
mock_Device.objects.filter.return_value = mock_filter
mock_Device.objects.create.return_value = MagicMock()
# Member A explicitly at position 2
# Member B has no position → sequential starts at 2 → taken → increments to 3 (line 435)
members_info = [
{"serial": "SN002", "position": 2, "name": "Member-explicit-2"},
{"serial": "SN003", "position": None, "name": "Member-no-pos"}, # triggers line 435
]
libre_device = {"device_id": 99}
create_virtual_chassis_with_members(master, members_info, libre_device)
mock_VirtualChassis.objects.create.assert_called_once()
create_calls = mock_Device.objects.create.call_args_list
positions_used = [c.kwargs.get("vc_position") for c in create_calls]
# First member gets explicit position 2; second (no position) gets 3 after 2 is taken
assert sorted(positions_used) == [2, 3]
actual_entries = sorted([(c.kwargs.get("serial"), c.kwargs.get("vc_position")) for c in create_calls])
assert actual_entries == [("SN002", 2), ("SN003", 3)]
@patch("netbox_librenms_plugin.import_utils.virtual_chassis.transaction")
@patch("netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern")
@patch("netbox_librenms_plugin.import_utils.virtual_chassis.VirtualChassis")
@patch("netbox_librenms_plugin.import_utils.virtual_chassis.Device")
def test_multiple_sequential_slots_taken_skips_all(
self, mock_Device, mock_VirtualChassis, mock_load_pattern, mock_transaction
):
"""Multiple sequential increments: position = 2, 3 all taken → gets 4."""
from netbox_librenms_plugin.import_utils.virtual_chassis import create_virtual_chassis_with_members
@contextmanager
def noop_atomic():
yield
mock_transaction.atomic = noop_atomic
mock_load_pattern.return_value = "-M{position}"
master = _make_master_device("MASTER001")
vc_mock = MagicMock()
vc_mock.members.count.return_value = 4
mock_VirtualChassis.objects.create.return_value = vc_mock
mock_filter = MagicMock()
mock_filter.exists.return_value = False
mock_filter.exclude.return_value = mock_filter
mock_Device.objects.filter.return_value = mock_filter
mock_Device.objects.create.return_value = MagicMock()
# Members at positions 2 and 3; then one with no position → should get 4
members_info = [
{"serial": "SN002", "position": 2, "name": "M2"},
{"serial": "SN003", "position": 3, "name": "M3"},
{"serial": "SN004", "position": None, "name": "M-no-pos"}, # should get 4
]
libre_device = {"device_id": 10}
create_virtual_chassis_with_members(master, members_info, libre_device)
create_calls = mock_Device.objects.create.call_args_list
positions_used = [c.kwargs.get("vc_position") for c in create_calls]
# Members at 2 and 3 are explicit; the member with no position gets 4
assert sorted(positions_used) == [2, 3, 4]
actual_entries = sorted([(c.kwargs.get("serial"), c.kwargs.get("vc_position")) for c in create_calls])
assert actual_entries == [("SN002", 2), ("SN003", 3), ("SN004", 4)]
@patch("netbox_librenms_plugin.import_utils.virtual_chassis.transaction")
@patch("netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern")
@patch("netbox_librenms_plugin.import_utils.virtual_chassis.VirtualChassis")
@patch("netbox_librenms_plugin.import_utils.virtual_chassis.Device")
def test_member_with_same_serial_as_master_is_skipped(
self, mock_Device, mock_VirtualChassis, mock_load_pattern, mock_transaction
):
"""Members with same serial as master device should be skipped."""
from netbox_librenms_plugin.import_utils.virtual_chassis import create_virtual_chassis_with_members
@contextmanager
def noop_atomic():
yield
mock_transaction.atomic = noop_atomic
mock_load_pattern.return_value = "-M{position}"
master = _make_master_device("MASTER_SERIAL")
vc_mock = MagicMock()
vc_mock.members.count.return_value = 1
mock_VirtualChassis.objects.create.return_value = vc_mock
mock_filter = MagicMock()
mock_filter.exists.return_value = False
mock_filter.exclude.return_value = mock_filter
mock_Device.objects.filter.return_value = mock_filter
mock_Device.objects.create.return_value = MagicMock()
members_info = [
{"serial": "MASTER_SERIAL", "position": 2, "name": "Master-dup"}, # skipped
{"serial": "SN999", "position": 3, "name": "Real member"},
]
libre_device = {"device_id": 5}
create_virtual_chassis_with_members(master, members_info, libre_device)
# Only one Device.objects.create for the non-duplicate member
create_calls = mock_Device.objects.create.call_args_list
assert len(create_calls) == 1
assert create_calls[0].kwargs.get("serial") == "SN999"
class TestCreateVirtualChassisServerKeyDomain:
"""Tests for server_key parameter in create_virtual_chassis_with_members domain."""
@patch("netbox_librenms_plugin.import_utils.virtual_chassis.transaction")
@patch("netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern")
@patch("netbox_librenms_plugin.import_utils.virtual_chassis.VirtualChassis")
@patch("netbox_librenms_plugin.import_utils.virtual_chassis.Device")
def test_server_key_included_in_domain(self, mock_Device, mock_VirtualChassis, mock_load_pattern, mock_transaction):
"""With server_key='production', domain should contain 'librenms-production-'."""
from contextlib import contextmanager
from netbox_librenms_plugin.import_utils.virtual_chassis import create_virtual_chassis_with_members
@contextmanager
def noop_atomic():
yield
mock_transaction.atomic = noop_atomic
mock_load_pattern.return_value = "-M{position}"
master = _make_master_device("SN001")
vc_mock = MagicMock()
vc_mock.members.count.return_value = 1
mock_VirtualChassis.objects.create.return_value = vc_mock
mock_filter = MagicMock()
mock_filter.exists.return_value = False
mock_filter.exclude.return_value = mock_filter
mock_Device.objects.filter.return_value = mock_filter
mock_Device.objects.create.return_value = MagicMock()
libre_device = {"device_id": 42}
create_virtual_chassis_with_members(master, [], libre_device, server_key="production")
call_kwargs = mock_VirtualChassis.objects.create.call_args.kwargs
assert "librenms-production-" in call_kwargs["domain"], f"domain was: {call_kwargs['domain']}"
assert "42" in call_kwargs["domain"]
@patch("netbox_librenms_plugin.import_utils.virtual_chassis.transaction")
@patch("netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern")
@patch("netbox_librenms_plugin.import_utils.virtual_chassis.VirtualChassis")
@patch("netbox_librenms_plugin.import_utils.virtual_chassis.Device")
def test_no_server_key_domain_prefix_is_librenms(
self, mock_Device, mock_VirtualChassis, mock_load_pattern, mock_transaction
):
"""Without server_key, domain should start with 'librenms-' (no server suffix)."""
from contextlib import contextmanager
from netbox_librenms_plugin.import_utils.virtual_chassis import create_virtual_chassis_with_members
@contextmanager
def noop_atomic():
yield
mock_transaction.atomic = noop_atomic
mock_load_pattern.return_value = "-M{position}"
master = _make_master_device("SN002")
vc_mock = MagicMock()
vc_mock.members.count.return_value = 1
mock_VirtualChassis.objects.create.return_value = vc_mock
mock_filter = MagicMock()
mock_filter.exists.return_value = False
mock_filter.exclude.return_value = mock_filter
mock_Device.objects.filter.return_value = mock_filter
mock_Device.objects.create.return_value = MagicMock()
libre_device = {"device_id": 99}
create_virtual_chassis_with_members(master, [], libre_device, server_key=None)
call_kwargs = mock_VirtualChassis.objects.create.call_args.kwargs
domain = call_kwargs["domain"]
assert domain.startswith("librenms-"), f"domain was: {domain}"
# Should not have a second prefix like 'librenms-None-'
assert "librenms-None" not in domain
assert "99" in domain

View File

@@ -0,0 +1,367 @@
"""
Coverage tests for netbox_librenms_plugin/tables/vlans.py
Tests cover all render methods and the configure() method of LibreNMSVLANTable.
"""
from unittest.mock import MagicMock, patch
def _make_table(data=None, vlan_groups=None):
"""Create a LibreNMSVLANTable instance with minimal data."""
from netbox_librenms_plugin.tables.vlans import LibreNMSVLANTable
return LibreNMSVLANTable(data=data or [], vlan_groups=vlan_groups)
# ===========================================================================
# __init__ / construction
# ===========================================================================
class TestLibreNMSVLANTableInit:
"""Tests for LibreNMSVLANTable.__init__()."""
def test_default_prefix_set(self):
table = _make_table()
assert table.prefix == "vlans_"
def test_vlan_groups_default_to_empty_list(self):
table = _make_table()
assert table.vlan_groups == []
def test_vlan_groups_stored_when_provided(self):
mock_group = MagicMock()
table = _make_table(vlan_groups=[mock_group])
assert table.vlan_groups == [mock_group]
def test_none_vlan_groups_normalised_to_empty_list(self):
table = _make_table(vlan_groups=None)
assert table.vlan_groups == []
# ===========================================================================
# render_vlan_id
# ===========================================================================
class TestRenderVlanId:
"""Tests for LibreNMSVLANTable.render_vlan_id()."""
def test_text_success_when_exists_and_name_matches(self):
table = _make_table()
record = {"exists_in_netbox": True, "name_matches": True}
html = str(table.render_vlan_id(100, record))
assert "text-success" in html
assert "100" in html
def test_text_warning_when_exists_but_name_mismatch(self):
table = _make_table()
record = {"exists_in_netbox": True, "name_matches": False}
html = str(table.render_vlan_id(200, record))
assert "text-warning" in html
assert "200" in html
def test_text_danger_when_not_in_netbox(self):
table = _make_table()
record = {"exists_in_netbox": False, "name_matches": True}
html = str(table.render_vlan_id(300, record))
assert "text-danger" in html
assert "300" in html
def test_default_name_matches_true_when_absent(self):
"""When name_matches key is absent, defaults to True → text-success if exists."""
table = _make_table()
record = {"exists_in_netbox": True} # name_matches key absent
html = str(table.render_vlan_id(10, record))
assert "text-success" in html
# ===========================================================================
# render_name
# ===========================================================================
class TestRenderName:
"""Tests for LibreNMSVLANTable.render_name()."""
def test_text_success_when_synced(self):
table = _make_table()
record = {"exists_in_netbox": True, "name_matches": True}
html = str(table.render_name("DATA", record))
assert "text-success" in html
assert "DATA" in html
def test_text_danger_when_not_in_netbox(self):
table = _make_table()
record = {"exists_in_netbox": False, "name_matches": True}
html = str(table.render_name("VOICE", record))
assert "text-danger" in html
assert "VOICE" in html
def test_tooltip_added_on_name_mismatch(self):
"""When exists_in_netbox=True and name_matches=False, tooltip with NetBox name is shown."""
table = _make_table()
record = {
"exists_in_netbox": True,
"name_matches": False,
"netbox_vlan_name": "OLD_NAME",
}
html = str(table.render_name("NEW_NAME", record))
assert "text-warning" in html
assert "NEW_NAME" in html
assert "OLD_NAME" in html
assert "title=" in html
def test_empty_name_rendered_as_empty_string(self):
"""render_name handles None/empty value."""
table = _make_table()
record = {"exists_in_netbox": False, "name_matches": True}
html = str(table.render_name(None, record))
assert "text-danger" in html
def test_tooltip_contains_both_names(self):
table = _make_table()
record = {
"exists_in_netbox": True,
"name_matches": False,
"netbox_vlan_name": "NetBox-VLANName",
}
html = str(table.render_name("LibreNMSName", record))
assert "NetBox-VLANName" in html
assert "LibreNMSName" in html
def test_no_tooltip_when_names_match(self):
table = _make_table()
record = {"exists_in_netbox": True, "name_matches": True}
html = str(table.render_name("MGMT", record))
# Tooltip (title=) should NOT be present when names match
assert 'title="' not in html
# ===========================================================================
# render_vlan_group_selection
# ===========================================================================
class TestRenderVlanGroupSelection:
"""Tests for LibreNMSVLANTable.render_vlan_group_selection()."""
def _make_group(self, pk, name, scope=None):
group = MagicMock()
group.pk = pk
group.name = name
group.scope = scope
return group
def test_select_element_rendered(self):
table = _make_table(vlan_groups=[self._make_group(1, "Site VLANs")])
record = {"vlan_id": 10, "name": "DATA", "exists_in_netbox": False}
html = str(table.render_vlan_group_selection(None, record))
assert "<select" in html
assert 'name="vlan_group_10"' in html
def test_no_selection_by_default(self):
"""When no auto-select criteria match, no option is pre-selected."""
group = self._make_group(1, "Global")
table = _make_table(vlan_groups=[group])
record = {"vlan_id": 5, "name": "TEST", "exists_in_netbox": False}
html = str(table.render_vlan_group_selection(None, record))
# 'selected' should not appear for the group option
assert "selected" not in html
def test_existing_netbox_vlan_group_preselected(self):
"""Priority 1: existing NetBox VLAN group is pre-selected."""
group = self._make_group(pk=7, name="Existing Group")
table = _make_table(vlan_groups=[group])
record = {
"vlan_id": 20,
"name": "EXISTING",
"exists_in_netbox": True,
"netbox_vlan_group_id": 7,
}
html = str(table.render_vlan_group_selection(None, record))
assert "selected" in html
def test_auto_selected_group_preselected(self):
"""Priority 2: auto_selected_group_id is pre-selected when exists_in_netbox is False."""
group = self._make_group(pk=3, name="Auto Group")
table = _make_table(vlan_groups=[group])
record = {
"vlan_id": 30,
"name": "AUTO",
"exists_in_netbox": False,
"auto_selected_group_id": 3,
}
html = str(table.render_vlan_group_selection(None, record))
assert "selected" in html
def test_warning_icon_when_ambiguous_and_not_in_netbox(self):
"""is_ambiguous=True and exists_in_netbox=False shows a warning icon."""
table = _make_table(vlan_groups=[])
record = {
"vlan_id": 40,
"name": "AMBIG",
"exists_in_netbox": False,
"is_ambiguous": True,
}
html = str(table.render_vlan_group_selection(None, record))
assert "mdi-alert" in html
def test_no_warning_icon_when_ambiguous_but_in_netbox(self):
"""Warning icon is NOT shown when exists_in_netbox=True even if is_ambiguous."""
table = _make_table(vlan_groups=[])
record = {
"vlan_id": 50,
"name": "IN_NB",
"exists_in_netbox": True,
"is_ambiguous": True,
"netbox_vlan_group_id": None,
}
html = str(table.render_vlan_group_selection(None, record))
assert "mdi-alert" not in html
def test_no_warning_icon_when_not_ambiguous(self):
"""No warning icon when is_ambiguous is False."""
table = _make_table(vlan_groups=[])
record = {
"vlan_id": 60,
"name": "CLEAR",
"exists_in_netbox": False,
"is_ambiguous": False,
}
html = str(table.render_vlan_group_selection(None, record))
assert "mdi-alert" not in html
def test_empty_groups_shows_no_group_option_only(self):
table = _make_table(vlan_groups=[])
record = {"vlan_id": 70, "name": "NOVLAN", "exists_in_netbox": False}
html = str(table.render_vlan_group_selection(None, record))
assert "No Group" in html
def test_scope_info_appended_when_scope_present(self):
"""If group.scope is truthy, scope string is included in option."""
group = self._make_group(pk=11, name="Rack VLANs", scope="rack1")
table = _make_table(vlan_groups=[group])
record = {"vlan_id": 80, "name": "RACK", "exists_in_netbox": False}
html = str(table.render_vlan_group_selection(None, record))
assert "rack1" in html
def test_no_scope_info_when_scope_is_falsy(self):
"""If group.scope is falsy, no extra parenthetical appears."""
group = self._make_group(pk=12, name="Global VLANs", scope=None)
table = _make_table(vlan_groups=[group])
record = {"vlan_id": 90, "name": "GLOBAL", "exists_in_netbox": False}
html = str(table.render_vlan_group_selection(None, record))
# The option text should just be the group name without extra suffix
assert "Global VLANs" in html
assert "(None)" not in html
def test_vlan_id_and_name_embedded_in_select(self):
table = _make_table(vlan_groups=[])
record = {"vlan_id": 100, "name": "MY_VLAN", "exists_in_netbox": False}
html = str(table.render_vlan_group_selection(None, record))
assert 'data-vlan-id="100"' in html
assert 'data-vlan-name="MY_VLAN"' in html
# ===========================================================================
# render_state
# ===========================================================================
class TestRenderState:
"""Tests for LibreNMSVLANTable.render_state()."""
def test_active_integer_one_renders_active(self):
"""LIBRENMS_VLAN_STATE_ACTIVE == 1 → 'Active' with text-success."""
from netbox_librenms_plugin.constants import LIBRENMS_VLAN_STATE_ACTIVE
table = _make_table()
html = str(table.render_state(LIBRENMS_VLAN_STATE_ACTIVE, {}))
assert "text-success" in html
assert "Active" in html
def test_active_string_renders_active(self):
"""'active' string also renders as Active."""
table = _make_table()
html = str(table.render_state("active", {}))
assert "text-success" in html
assert "Active" in html
def test_other_value_renders_inactive(self):
table = _make_table()
html = str(table.render_state(0, {}))
assert "text-muted" in html
assert "Inactive" in html
def test_unknown_string_renders_inactive(self):
table = _make_table()
html = str(table.render_state("inactive", {}))
assert "text-muted" in html
assert "Inactive" in html
def test_none_renders_inactive(self):
table = _make_table()
html = str(table.render_state(None, {}))
assert "text-muted" in html
# ===========================================================================
# configure()
# ===========================================================================
class TestLibreNMSVLANTableConfigure:
"""Tests for LibreNMSVLANTable.configure()."""
def test_configure_calls_request_config(self):
from netbox_librenms_plugin.tables.vlans import LibreNMSVLANTable
table = LibreNMSVLANTable(data=[])
mock_request = MagicMock()
with patch("netbox_librenms_plugin.tables.vlans.tables.RequestConfig") as mock_rc_cls:
with patch("netbox_librenms_plugin.tables.vlans.get_table_paginate_count", return_value=50):
mock_rc_instance = MagicMock()
mock_rc_cls.return_value = mock_rc_instance
table.configure(mock_request)
mock_rc_cls.assert_called_once()
mock_rc_instance.configure.assert_called_once_with(table)
def test_configure_passes_enhanced_paginator(self):
from netbox_librenms_plugin.tables.vlans import LibreNMSVLANTable
from utilities.paginator import EnhancedPaginator
table = LibreNMSVLANTable(data=[])
mock_request = MagicMock()
captured_paginate = {}
def capture_rc(request, paginate):
captured_paginate.update(paginate)
rc = MagicMock()
rc.configure = MagicMock()
return rc
with patch("netbox_librenms_plugin.tables.vlans.tables.RequestConfig", side_effect=capture_rc):
with patch("netbox_librenms_plugin.tables.vlans.get_table_paginate_count", return_value=25):
table.configure(mock_request)
assert captured_paginate.get("paginator_class") is EnhancedPaginator
assert captured_paginate.get("per_page") == 25
def test_configure_uses_table_prefix_for_paginate_count(self):
from netbox_librenms_plugin.tables.vlans import LibreNMSVLANTable
table = LibreNMSVLANTable(data=[])
mock_request = MagicMock()
with patch("netbox_librenms_plugin.tables.vlans.tables.RequestConfig") as mock_rc_cls:
mock_rc_cls.return_value.configure = MagicMock()
with patch("netbox_librenms_plugin.tables.vlans.get_table_paginate_count") as mock_paginate:
mock_paginate.return_value = 10
table.configure(mock_request)
mock_paginate.assert_called_once_with(mock_request, "vlans_")

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,368 @@
"""
Tests for netbox_librenms_plugin.import_validation_helpers module.
Phase 2 tests covering validation state updates, model retrieval,
and selection extraction functions.
"""
from unittest.mock import MagicMock
# =============================================================================
# TestGetModelById - 4 tests
# =============================================================================
class TestFetchModelById:
"""Test generic model retrieval helper."""
def test_fetch_model_by_id_success(self):
"""Return model instance when found."""
mock_model_class = MagicMock()
mock_instance = MagicMock(id=1, name="Access Switch")
mock_model_class.objects.get.return_value = mock_instance
from netbox_librenms_plugin.import_validation_helpers import fetch_model_by_id
result = fetch_model_by_id(mock_model_class, 1)
assert result == mock_instance
mock_model_class.objects.get.assert_called_once_with(pk=1)
def test_fetch_model_by_id_not_found(self):
"""Return None when ID doesn't exist."""
mock_model_class = MagicMock()
mock_model_class.DoesNotExist = Exception
mock_model_class.objects.get.side_effect = mock_model_class.DoesNotExist
from netbox_librenms_plugin.import_validation_helpers import fetch_model_by_id
result = fetch_model_by_id(mock_model_class, 999)
assert result is None
def test_fetch_model_by_id_invalid_id(self):
"""Handle invalid ID gracefully."""
mock_model_class = MagicMock()
mock_model_class.DoesNotExist = type("DoesNotExist", (Exception,), {})
from netbox_librenms_plugin.import_validation_helpers import fetch_model_by_id
result = fetch_model_by_id(mock_model_class, "not-a-number")
assert result is None
def test_fetch_model_by_id_none_id(self):
"""Handle None ID gracefully."""
mock_model_class = MagicMock()
from netbox_librenms_plugin.import_validation_helpers import fetch_model_by_id
result = fetch_model_by_id(mock_model_class, None)
assert result is None
mock_model_class.objects.get.assert_not_called()
# =============================================================================
# TestExtractSelections - 4 tests
# =============================================================================
class TestExtractDeviceSelections:
"""Test extraction of device selections from request."""
def test_extract_selections_all_present(self):
"""All selections extracted from POST request."""
from netbox_librenms_plugin.import_validation_helpers import (
extract_device_selections,
)
mock_request = MagicMock()
mock_request.method = "POST"
mock_request.POST = {
"cluster_1234": "5",
"role_1234": "10",
"rack_1234": "15",
}
result = extract_device_selections(mock_request, device_id=1234)
assert result["cluster_id"] == "5"
assert result["role_id"] == "10"
assert result["rack_id"] == "15"
def test_extract_selections_partial(self):
"""Missing fields return None."""
from netbox_librenms_plugin.import_validation_helpers import (
extract_device_selections,
)
mock_request = MagicMock()
mock_request.method = "POST"
mock_request.POST = {
"role_1234": "10",
}
result = extract_device_selections(mock_request, device_id=1234)
assert result["cluster_id"] is None
assert result["role_id"] == "10"
assert result["rack_id"] is None
def test_extract_selections_from_get(self):
"""Selections extracted from GET request."""
from netbox_librenms_plugin.import_validation_helpers import (
extract_device_selections,
)
mock_request = MagicMock()
mock_request.method = "GET"
mock_request.GET = {
"cluster_999": "3",
"role_999": "7",
"rack_999": "11",
}
result = extract_device_selections(mock_request, device_id=999)
assert result["cluster_id"] == "3"
assert result["role_id"] == "7"
assert result["rack_id"] == "11"
def test_extract_selections_empty_values(self):
"""Empty strings handled correctly."""
from netbox_librenms_plugin.import_validation_helpers import (
extract_device_selections,
)
mock_request = MagicMock()
mock_request.method = "POST"
mock_request.POST = {
"cluster_1234": "",
"role_1234": "",
"rack_1234": "",
}
result = extract_device_selections(mock_request, device_id=1234)
# Empty strings are returned as-is (caller decides meaning)
assert result["cluster_id"] == ""
assert result["role_id"] == ""
assert result["rack_id"] == ""
# =============================================================================
# TestValidationStateUpdates - 10 tests
# =============================================================================
class TestValidationStateUpdates:
"""Test validation state mutation functions."""
def test_apply_role_to_validation_success(self):
"""Role selection updates state correctly."""
from netbox_librenms_plugin.import_validation_helpers import (
apply_role_to_validation,
)
mock_role = MagicMock(id=1, name="Access Switch")
validation = {
"device_role": {"found": False, "role": None},
"issues": ["Device role must be manually selected before import"],
"can_import": False,
"is_ready": False,
"site": {"found": True},
"device_type": {"found": True},
}
apply_role_to_validation(validation, mock_role, is_vm=False)
assert validation["device_role"]["found"] is True
assert validation["device_role"]["role"] == mock_role
def test_apply_role_to_validation_clears_issue(self):
"""Selecting role should clear 'role' related validation issue."""
from netbox_librenms_plugin.import_validation_helpers import (
apply_role_to_validation,
)
mock_role = MagicMock(id=1, name="Access Switch")
validation = {
"device_role": {"found": False, "role": None},
"issues": ["Device role must be manually selected before import"],
"can_import": False,
"is_ready": False,
"site": {"found": True},
"device_type": {"found": True},
}
apply_role_to_validation(validation, mock_role, is_vm=False)
assert len(validation["issues"]) == 0
def test_apply_cluster_to_validation_success(self):
"""Cluster selection updates state for VM import."""
from netbox_librenms_plugin.import_validation_helpers import (
apply_cluster_to_validation,
)
mock_cluster = MagicMock(id=1, name="VMware Cluster 1")
validation = {
"cluster": {"found": False, "cluster": None},
"issues": ["Cluster must be manually selected before import"],
"can_import": False,
"is_ready": False,
}
apply_cluster_to_validation(validation, mock_cluster)
assert validation["cluster"]["found"] is True
assert validation["cluster"]["cluster"] == mock_cluster
def test_apply_rack_to_validation_success(self):
"""Rack selection updates state for device import."""
from netbox_librenms_plugin.import_validation_helpers import (
apply_rack_to_validation,
)
mock_rack = MagicMock(id=1, name="Rack A1")
validation = {
"issues": [],
"can_import": True,
"is_ready": True,
}
apply_rack_to_validation(validation, mock_rack)
assert validation["rack"]["found"] is True
assert validation["rack"]["rack"] == mock_rack
def test_remove_validation_issue_single(self):
"""Remove single issue by keyword."""
from netbox_librenms_plugin.import_validation_helpers import (
remove_validation_issue,
)
validation = {
"issues": [
"Device role must be manually selected before import",
"Site not found for location 'DC1'",
]
}
remove_validation_issue(validation, "role")
assert len(validation["issues"]) == 1
assert "Site not found" in validation["issues"][0]
def test_remove_validation_issue_multiple(self):
"""Remove multiple matching issues."""
from netbox_librenms_plugin.import_validation_helpers import (
remove_validation_issue,
)
validation = {
"issues": [
"Device role must be selected",
"Role is required for import",
"Site not found",
]
}
remove_validation_issue(validation, "role")
assert len(validation["issues"]) == 1
assert "Site not found" in validation["issues"][0]
def test_remove_validation_issue_no_match(self):
"""No change when keyword not found."""
from netbox_librenms_plugin.import_validation_helpers import (
remove_validation_issue,
)
validation = {
"issues": [
"Site not found for location 'DC1'",
"Device type not matched",
]
}
remove_validation_issue(validation, "cluster")
assert len(validation["issues"]) == 2
def test_recalculate_can_import_all_ready_device(self):
"""can_import=True when all requirements met for device."""
from netbox_librenms_plugin.import_validation_helpers import (
recalculate_validation_status,
)
validation = {
"issues": [],
"can_import": False,
"is_ready": False,
"site": {"found": True},
"device_type": {"found": True},
"device_role": {"found": True},
}
recalculate_validation_status(validation, is_vm=False)
assert validation["can_import"] is True
assert validation["is_ready"] is True
def test_recalculate_can_import_missing_required_device(self):
"""can_import=False when required field missing for device."""
from netbox_librenms_plugin.import_validation_helpers import (
recalculate_validation_status,
)
validation = {
"issues": ["Site not found"],
"can_import": True, # Should become False
"is_ready": True,
"site": {"found": False},
"device_type": {"found": True},
"device_role": {"found": True},
}
recalculate_validation_status(validation, is_vm=False)
assert validation["can_import"] is False
assert validation["is_ready"] is False
def test_recalculate_can_import_vm_cluster_required(self):
"""VM import requires cluster to be ready."""
from netbox_librenms_plugin.import_validation_helpers import (
recalculate_validation_status,
)
validation = {
"issues": [],
"can_import": False,
"is_ready": False,
"cluster": {"found": True},
}
recalculate_validation_status(validation, is_vm=True)
assert validation["can_import"] is True
assert validation["is_ready"] is True
def test_recalculate_can_import_vm_missing_cluster(self):
"""VM import not ready without cluster."""
from netbox_librenms_plugin.import_validation_helpers import (
recalculate_validation_status,
)
validation = {
"issues": [],
"can_import": False,
"is_ready": False,
"cluster": {"found": False},
}
recalculate_validation_status(validation, is_vm=True)
assert validation["can_import"] is True # No issues
assert validation["is_ready"] is False # But not ready without cluster

View File

@@ -0,0 +1,216 @@
"""Tests for netbox_librenms_plugin.__init__ module.
Covers the _ensure_librenms_id_custom_field post_migrate signal handler.
"""
from unittest.mock import MagicMock, patch
# =============================================================================
# TestEnsureLibreNMSIdCustomField - 6 tests
# =============================================================================
class TestEnsureLibreNMSIdCustomField:
"""Test _ensure_librenms_id_custom_field signal handler."""
def setup_method(self):
"""Reset per-alias execution tracking before each test."""
from netbox_librenms_plugin import _ensure_librenms_id_custom_field
_ensure_librenms_id_custom_field._executed_aliases = set()
def _setup_cf_mock(self, MockCustomField, mock_cf, created):
"""Wire MockCustomField so that objects.using(alias).get_or_create(...) returns (mock_cf, created)."""
MockCustomField.objects.using.return_value.get_or_create.return_value = (mock_cf, created)
def _setup_ct_mock(self, MockContentType, mock_ct):
"""Wire MockContentType so that objects.db_manager(alias).get_for_model(...) returns mock_ct."""
MockContentType.objects.db_manager.return_value.get_for_model.return_value = mock_ct
@patch("dcim.models.Interface", new_callable=MagicMock)
@patch("dcim.models.Device", new_callable=MagicMock)
@patch("virtualization.models.VMInterface", new_callable=MagicMock)
@patch("virtualization.models.VirtualMachine", new_callable=MagicMock)
@patch("django.contrib.contenttypes.models.ContentType")
@patch("extras.models.CustomField")
def test_creates_custom_field_when_missing(
self, MockCustomField, MockContentType, mock_vm, mock_vmif, mock_device, mock_iface
):
"""Custom field is created with correct defaults when it does not exist."""
from netbox_librenms_plugin import _ensure_librenms_id_custom_field
mock_cf = MagicMock()
mock_cf.object_types.values_list.return_value = []
self._setup_cf_mock(MockCustomField, mock_cf, True)
mock_ct = MagicMock()
mock_ct.pk = 1
self._setup_ct_mock(MockContentType, mock_ct)
with patch("logging.getLogger") as mock_get_logger:
_ensure_librenms_id_custom_field(sender=None)
MockCustomField.objects.using.return_value.get_or_create.assert_called_once_with(
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,
},
)
# Should have added content types for all 4 models
assert mock_cf.object_types.add.call_count == 4
# Should log when created
mock_get_logger.assert_called_with("netbox_librenms_plugin")
def test_skips_when_already_executed(self):
"""Handler is a no-op on second invocation for the same DB alias."""
from netbox_librenms_plugin import _ensure_librenms_id_custom_field
_ensure_librenms_id_custom_field._executed_aliases = {"default"}
with patch("extras.models.CustomField") as MockCustomField:
_ensure_librenms_id_custom_field(sender=None)
MockCustomField.objects.using.assert_not_called()
@patch("dcim.models.Interface", new_callable=MagicMock)
@patch("dcim.models.Device", new_callable=MagicMock)
@patch("virtualization.models.VMInterface", new_callable=MagicMock)
@patch("virtualization.models.VirtualMachine", new_callable=MagicMock)
@patch("django.contrib.contenttypes.models.ContentType")
@patch("extras.models.CustomField")
def test_existing_field_not_recreated(
self, MockCustomField, MockContentType, mock_vm, mock_vmif, mock_device, mock_iface
):
"""When custom field already exists, it is not recreated but types are checked."""
from netbox_librenms_plugin import _ensure_librenms_id_custom_field
mock_cf = MagicMock()
mock_cf.object_types.values_list.return_value = [1, 2, 3, 4]
self._setup_cf_mock(MockCustomField, mock_cf, False)
mock_ct = MagicMock()
mock_ct.pk = 1
self._setup_ct_mock(MockContentType, mock_ct)
_ensure_librenms_id_custom_field(sender=None)
# All pks already present, no types should be added
mock_cf.object_types.add.assert_not_called()
@patch("dcim.models.Interface", new_callable=MagicMock)
@patch("dcim.models.Device", new_callable=MagicMock)
@patch("virtualization.models.VMInterface", new_callable=MagicMock)
@patch("virtualization.models.VirtualMachine", new_callable=MagicMock)
@patch("django.contrib.contenttypes.models.ContentType")
@patch("extras.models.CustomField")
def test_adds_missing_content_types(
self, MockCustomField, MockContentType, mock_vm, mock_vmif, mock_device, mock_iface
):
"""When some content types are missing, only those are added."""
from netbox_librenms_plugin import _ensure_librenms_id_custom_field
mock_cf = MagicMock()
mock_cf.object_types.values_list.return_value = [1, 2]
self._setup_cf_mock(MockCustomField, mock_cf, False)
ct_existing = MagicMock()
ct_existing.pk = 1
ct_new = MagicMock()
ct_new.pk = 99
MockContentType.objects.db_manager.return_value.get_for_model.side_effect = [
ct_existing,
ct_existing,
ct_new,
ct_new,
]
_ensure_librenms_id_custom_field(sender=None)
assert mock_cf.object_types.add.call_count == 2
mock_cf.object_types.add.assert_any_call(ct_new)
@patch("extras.models.CustomField")
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.using.return_value.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]
@patch("dcim.models.Interface", new_callable=MagicMock)
@patch("dcim.models.Device", new_callable=MagicMock)
@patch("virtualization.models.VMInterface", new_callable=MagicMock)
@patch("virtualization.models.VirtualMachine", new_callable=MagicMock)
@patch("django.contrib.contenttypes.models.ContentType")
@patch("extras.models.CustomField")
def test_no_log_when_field_already_exists(
self, MockCustomField, MockContentType, mock_vm, mock_vmif, mock_device, mock_iface
):
"""No log message when the custom field already existed."""
from netbox_librenms_plugin import _ensure_librenms_id_custom_field
mock_cf = MagicMock()
mock_cf.object_types.values_list.return_value = [1, 2, 3, 4]
self._setup_cf_mock(MockCustomField, mock_cf, False)
mock_ct = MagicMock()
mock_ct.pk = 1
self._setup_ct_mock(MockContentType, mock_ct)
with patch("logging.getLogger") as mock_get_logger:
_ensure_librenms_id_custom_field(sender=None)
# When the field already exists (created=False), the info log should
# not be emitted. We verify via the logger instance rather than
# asserting getLogger was never called, which is fragile.
logger_instance = mock_get_logger.return_value
logger_instance.info.assert_not_called()
@patch("dcim.models.Interface", new_callable=MagicMock)
@patch("dcim.models.Device", new_callable=MagicMock)
@patch("virtualization.models.VMInterface", new_callable=MagicMock)
@patch("virtualization.models.VirtualMachine", new_callable=MagicMock)
@patch("django.contrib.contenttypes.models.ContentType")
@patch("extras.models.CustomField")
def test_integer_field_migrated_to_json(
self, MockCustomField, MockContentType, mock_vm, mock_vmif, mock_device, mock_iface
):
"""When existing field has type='integer', it is migrated to 'json' and saved on the given alias."""
from netbox_librenms_plugin import _ensure_librenms_id_custom_field
db_alias = "other"
mock_cf = MagicMock()
mock_cf.type = "integer"
mock_cf.object_types.values_list.return_value = [1, 2, 3, 4]
self._setup_cf_mock(MockCustomField, mock_cf, False)
mock_ct = MagicMock()
mock_ct.pk = 1
self._setup_ct_mock(MockContentType, mock_ct)
_ensure_librenms_id_custom_field(sender=None, using=db_alias)
assert mock_cf.type == "json"
# Alias must flow through both the CustomField lookup and the ContentType lookup,
# exercising the per-alias guard path (_executed_aliases).
MockCustomField.objects.using.assert_called_with(db_alias)
MockContentType.objects.db_manager.assert_called_with(db_alias)
mock_cf.save.assert_called_once_with(using=db_alias, update_fields=["type"])
assert db_alias in _ensure_librenms_id_custom_field._executed_aliases

View File

@@ -0,0 +1,399 @@
"""
Integration tests using the mock LibreNMS HTTP server.
These tests verify that LibreNMSAPI correctly parses responses from a real
(but local, mocked) HTTP server, and that the full request/response cycle works.
No Django database access is used; NetBox model interactions are mocked.
"""
import json
import pytest
from netbox_librenms_plugin.tests.mock_librenms_server import librenms_mock_server
@pytest.fixture
def mock_server():
with librenms_mock_server() as server:
yield server
def _make_api(url, token="test-token"):
"""Create a LibreNMSAPI instance pointed at the mock server."""
from unittest.mock import patch
from netbox_librenms_plugin.librenms_api import LibreNMSAPI
servers_config = {
"test": {
"librenms_url": url,
"api_token": token,
"cache_timeout": 0,
"verify_ssl": False,
}
}
with patch("netbox_librenms_plugin.librenms_api.get_plugin_config") as mock_cfg:
mock_cfg.side_effect = lambda _plugin, key: servers_config if key == "servers" else None
api = LibreNMSAPI(server_key="test")
assert api.server_key == "test"
return api
class TestMockServerSanity:
"""The mock server itself must start, serve, and stop cleanly."""
def test_server_starts_and_responds(self, mock_server):
import urllib.request
mock_server.register("/api/v0/test", {"status": "ok"})
with urllib.request.urlopen(f"{mock_server.url}/api/v0/test") as resp:
data = json.loads(resp.read())
assert data["status"] == "ok"
def test_404_for_unregistered_path(self, mock_server):
import urllib.request
from urllib.error import HTTPError
try:
urllib.request.urlopen(f"{mock_server.url}/api/v0/nonexistent")
except HTTPError as e:
assert e.code == 404
else:
pytest.fail("Expected 404 HTTPError")
class TestLibreNMSAPIPortsFetch:
"""LibreNMSAPI.get_ports() correctly parses mock server responses."""
def test_get_ports_returns_dict_with_ports_key(self, mock_server):
"""
get_ports() returns a parsed dict and sends the required query parameters.
A callable route is used so we can capture the outgoing query string and
assert that both the ``columns`` field list and ``with=vlans`` are present —
if either is ever dropped, the sync page will silently lose data.
"""
captured_query: dict = {}
ports_body = {
"status": "ok",
"ports": [
{
"port_id": 101,
"ifName": "GigabitEthernet0/1",
"ifDescr": "GigabitEthernet0/1",
"ifType": "ethernetCsmacd",
"ifSpeed": 1_000_000_000,
"ifAdminStatus": "up",
"ifAlias": "uplink",
"ifPhysAddress": "aa:bb:cc:dd:ee:01",
"ifMtu": 1500,
"ifVlan": 1,
"ifTrunk": 0,
}
],
}
def _route(method, path, query, headers, body):
# query is already parsed by the mock server (dict of lists)
captured_query.update(query)
return 200, ports_body
mock_server.routes["/api/v0/devices/1/ports"] = _route
api = _make_api(mock_server.url)
success, data = api.get_ports(1)
assert success is True
assert isinstance(data, dict)
assert "ports" in data
assert data["ports"][0]["ifName"] == "GigabitEthernet0/1"
assert "columns" in captured_query, "get_ports() must send a 'columns' query param"
assert "vlans" in captured_query.get("with", []), "get_ports() must request 'with=vlans'"
def test_get_ports_returns_false_on_auth_error(self, mock_server):
mock_server.auth_error_response(path="/api/v0/devices/1/ports")
api = _make_api(mock_server.url)
success, _ = api.get_ports(1)
assert success is False
def test_get_ports_empty_list_when_no_ports(self, mock_server):
mock_server.register("/api/v0/devices/99/ports", {"status": "ok", "ports": []})
api = _make_api(mock_server.url)
success, data = api.get_ports(99)
assert success is True
assert data["ports"] == []
class TestLibreNMSAPIDeviceInfo:
"""LibreNMSAPI.get_device_info() correctly parses device details."""
def test_returns_device_info_dict(self, mock_server):
mock_server.device_info_response(device_id=5, hostname="rtr01", hardware="ISR4351")
api = _make_api(mock_server.url)
success, info = api.get_device_info(5)
assert success is True
assert isinstance(info, dict)
assert info["hostname"] == "rtr01"
def test_returns_false_on_404(self, mock_server):
# /api/v0/devices/999 not registered → 404
api = _make_api(mock_server.url)
success, info = api.get_device_info(999)
assert success is False
assert info is None
class TestLibreNMSAPIAddDevice:
"""LibreNMSAPI.add_device() posts correctly and interprets the response."""
def test_add_device_success(self, mock_server):
mock_server.add_device_response(device_id=10)
api = _make_api(mock_server.url)
success, message = api.add_device(
{
"hostname": "switch1.example.com",
"snmp_version": "v2c",
"community": "public",
"force_add": False,
}
)
assert success is True
def test_add_device_failure_on_server_error(self, mock_server):
mock_server.register("/api/v0/devices", {"status": "error", "message": "duplicate"}, status=500)
api = _make_api(mock_server.url)
success, message = api.add_device(
{
"hostname": "dup.example.com",
"snmp_version": "v2c",
"community": "public",
}
)
assert success is False
class TestLibreNMSAPIInventory:
"""LibreNMSAPI.get_device_inventory() correctly parses mock server responses."""
def test_returns_inventory_list(self, mock_server):
inventory = [
{
"entPhysicalIndex": 1,
"entPhysicalDescr": "Chassis",
"entPhysicalClass": "chassis",
"entPhysicalSerialNum": "SN-CHASSIS-001",
"entPhysicalModelName": "WS-C4900M",
"entPhysicalName": "Chassis 1",
"entPhysicalContainedIn": 0,
},
{
"entPhysicalIndex": 2,
"entPhysicalDescr": "Linecard",
"entPhysicalClass": "module",
"entPhysicalSerialNum": "SN-CARD-002",
"entPhysicalModelName": "WS-X4748-RJ45V+E",
"entPhysicalName": "Slot 1",
"entPhysicalContainedIn": 1,
},
]
mock_server.register("/api/v0/inventory/7/all", {"status": "ok", "inventory": inventory})
api = _make_api(mock_server.url)
success, data = api.get_device_inventory(7)
assert success is True
assert isinstance(data, list)
assert len(data) == 2
assert data[0]["entPhysicalClass"] == "chassis"
assert data[1]["entPhysicalModelName"] == "WS-X4748-RJ45V+E"
def test_returns_empty_list_when_no_inventory(self, mock_server):
mock_server.register("/api/v0/inventory/99/all", {"status": "ok", "inventory": []})
api = _make_api(mock_server.url)
success, data = api.get_device_inventory(99)
assert success is True
assert data == []
def test_returns_false_on_network_error(self, mock_server):
# Unregistered path → 404 → raise_for_status → RequestException
api = _make_api(mock_server.url)
success, _ = api.get_device_inventory(404)
assert success is False
def test_inventory_items_preserve_all_fields(self, mock_server):
inventory = [
{
"entPhysicalIndex": 5,
"entPhysicalDescr": "10 Gigabit Ethernet Module",
"entPhysicalClass": "module",
"entPhysicalSerialNum": "JAE123XYZ",
"entPhysicalModelName": "X2-10GB-LR",
"entPhysicalName": "TenGigabitEthernet1/1",
"entPhysicalContainedIn": 1,
"entPhysicalParentRelPos": 1,
}
]
mock_server.register("/api/v0/inventory/3/all", {"status": "ok", "inventory": inventory})
api = _make_api(mock_server.url)
success, data = api.get_device_inventory(3)
assert success is True
item = data[0]
assert item["entPhysicalParentRelPos"] == 1
assert item["entPhysicalSerialNum"] == "JAE123XYZ"
class TestLibreNMSAPIDiscovery:
"""
LibreNMSAPI device-ID discovery: lookup by IP and hostname fallback.
Covers get_device_id_by_ip(), get_device_id_by_hostname(), and the
get_librenms_id() fallback chain (IP → DNS name → hostname).
"""
_DEVICE_RESPONSE = {
"status": "ok",
"devices": [{"device_id": 42, "hostname": "sw01.example.com"}],
}
def test_get_device_id_by_ip_returns_id(self, mock_server):
mock_server.register("/api/v0/devices/10.0.0.1", self._DEVICE_RESPONSE)
api = _make_api(mock_server.url)
device_id = api.get_device_id_by_ip("10.0.0.1")
assert device_id == 42
def test_get_device_id_by_ip_returns_none_on_404(self, mock_server):
# no route registered → 404
api = _make_api(mock_server.url)
device_id = api.get_device_id_by_ip("10.0.0.2")
assert device_id is None
def test_get_device_id_by_hostname_returns_id(self, mock_server):
mock_server.register("/api/v0/devices/sw01.example.com", self._DEVICE_RESPONSE)
api = _make_api(mock_server.url)
device_id = api.get_device_id_by_hostname("sw01.example.com")
assert device_id == 42
def test_get_device_id_by_hostname_returns_none_on_404(self, mock_server):
api = _make_api(mock_server.url)
device_id = api.get_device_id_by_hostname("unknown.example.com")
assert device_id is None
def test_get_librenms_id_resolves_by_ip(self, mock_server):
"""get_librenms_id() resolves via IP when the device has a primary_ip."""
from unittest.mock import MagicMock, patch
mock_server.register("/api/v0/devices/10.0.0.10", self._DEVICE_RESPONSE)
api = _make_api(mock_server.url)
obj = MagicMock()
obj.cf = {} # no stored ID
obj.primary_ip.address.ip = "10.0.0.10"
obj.primary_ip.dns_name = None
obj.name = "sw01"
with patch.object(api, "_get_cache_key", return_value="test-key"):
with patch("netbox_librenms_plugin.librenms_api.cache") as mock_cache:
mock_cache.get.return_value = None
with patch.object(api, "_store_librenms_id"):
result = api.get_librenms_id(obj)
assert result == 42
def test_get_librenms_id_falls_back_to_hostname_when_ip_fails(self, mock_server):
"""get_librenms_id() falls back to hostname when IP lookup returns no result."""
from unittest.mock import MagicMock, patch
# IP path returns 404 (unregistered) → fallback to hostname
mock_server.register("/api/v0/devices/sw01.example.com", self._DEVICE_RESPONSE)
api = _make_api(mock_server.url)
obj = MagicMock()
obj.cf = {} # no stored ID
obj.primary_ip.address.ip = "192.0.2.1" # unregistered → 404 → None
obj.primary_ip.dns_name = None
obj.name = "sw01.example.com"
with patch.object(api, "_get_cache_key", return_value="test-key"):
with patch("netbox_librenms_plugin.librenms_api.cache") as mock_cache:
mock_cache.get.return_value = None
with patch.object(api, "_store_librenms_id"):
result = api.get_librenms_id(obj)
assert result == 42
class TestLibreNMSAPIErrorResponses:
"""Integration tests: API client handles unusual server responses gracefully."""
def test_401_returns_false(self, mock_server):
"""HTTP 401 Unauthorized must return (False, ...) not raise."""
api = _make_api(mock_server.url)
mock_server.register("/api/v0/devices", {"message": "Unauthorized"}, status=401)
success, data = api.list_devices()
assert success is False
def test_500_returns_false(self, mock_server):
"""HTTP 500 must return (False, ...) not raise."""
api = _make_api(mock_server.url)
mock_server.register("/api/v0/devices", {"message": "Internal error"}, status=500)
success, data = api.list_devices()
assert success is False
def test_null_inventory_returns_false(self, mock_server):
"""{"status":"ok","inventory":null} must not raise, must return (False, error_string)."""
api = _make_api(mock_server.url)
mock_server.register("/api/v0/inventory/1/all", {"status": "ok", "inventory": None})
success, data = api.get_device_inventory(1)
assert success is False
assert isinstance(data, str) and data, "expected a non-empty error string describing the malformed inventory"
def test_null_devices_field_get_device_info(self, mock_server):
"""{"devices": null} in get_device_info must return (False, None) not crash."""
api = _make_api(mock_server.url)
mock_server.register("/api/v0/devices/42", {"devices": None})
success, data = api.get_device_info(42)
assert success is False
assert data is None
def test_empty_devices_list_get_device_info(self, mock_server):
"""{"devices": []} in get_device_info must return (False, None)."""
api = _make_api(mock_server.url)
mock_server.register("/api/v0/devices/42", {"devices": []})
success, data = api.get_device_info(42)
assert success is False
assert data is None
def test_404_get_device_info(self, mock_server):
"""404 on device endpoint must return (False, None)."""
api = _make_api(mock_server.url)
mock_server.register("/api/v0/devices/99", {"message": "Device not found"}, status=404)
success, data = api.get_device_info(99)
assert success is False
assert data is None

View File

@@ -0,0 +1,854 @@
"""
Integration tests for Virtual Chassis detection using the mock LibreNMS HTTP server.
These tests verify that detect_virtual_chassis_from_inventory(), get_virtual_chassis_data(),
and prefetch_vc_data_for_devices() work correctly end-to-end through real HTTP calls
to a local mock server — no mocking of the detection logic itself.
Run:
python -m pytest netbox_librenms_plugin/tests/test_integration_virtual_chassis.py -v
"""
import pytest
from unittest.mock import patch
from netbox_librenms_plugin.tests.mock_librenms_server import librenms_mock_server
@pytest.fixture
def mock_server():
with librenms_mock_server() as server:
yield server
def _make_api(url, token="test-token", server_key="test"):
"""Create a LibreNMSAPI instance pointed at the mock server."""
from netbox_librenms_plugin.librenms_api import LibreNMSAPI
servers_config = {
server_key: {
"librenms_url": url,
"api_token": token,
"cache_timeout": 300,
"verify_ssl": False,
}
}
with patch("netbox_librenms_plugin.librenms_api.get_plugin_config") as mock_cfg:
mock_cfg.side_effect = lambda _plugin, key: servers_config if key == "servers" else None
api = LibreNMSAPI(server_key=server_key)
return api
def _chassis(index, serial, model="WS-C3750X", name="", descr="", position=None, contained_in=None):
"""Build a minimal ENTITY-MIB chassis entry."""
item = {
"entPhysicalIndex": index,
"entPhysicalClass": "chassis",
"entPhysicalSerialNum": serial,
"entPhysicalModelName": model,
"entPhysicalName": name or f"Chassis-{index}",
"entPhysicalDescr": descr or f"Chassis {index}",
}
if position is not None:
item["entPhysicalParentRelPos"] = position
if contained_in is not None:
item["entPhysicalContainedIn"] = contained_in
return item
def _stack_root(index=1):
"""Build a 'stack' class root entry (e.g., Cisco StackWise)."""
return {
"entPhysicalIndex": index,
"entPhysicalClass": "stack",
"entPhysicalSerialNum": "",
"entPhysicalModelName": "",
"entPhysicalName": "StackSub-0/0",
"entPhysicalDescr": "Cisco StackWise",
"entPhysicalContainedIn": 0,
}
class TestDetectVCCiscoStack:
"""Cisco StackWise topology: root has stack-class entry; children are chassis members."""
def test_three_member_stack(self, mock_server):
"""3 chassis members under a stack root → is_stack=True, member_count=3."""
from netbox_librenms_plugin.import_utils.virtual_chassis import detect_virtual_chassis_from_inventory
api = _make_api(mock_server.url)
device_id = 10
mock_server.device_info_response(device_id=device_id, hostname="sw-stack", serial="MASTER")
root_items = [_stack_root(index=1)]
member_items = [
_chassis(100, "SN-A", position=1),
_chassis(200, "SN-B", position=2),
_chassis(300, "SN-C", position=3),
]
mock_server.vc_inventory_callable(device_id, root_items, {1: member_items})
with patch(
"netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern",
return_value="{master}-m{position}",
):
result = detect_virtual_chassis_from_inventory(api, device_id)
assert result is not None
assert result["is_stack"] is True
assert result["member_count"] == 3
serials = [m["serial"] for m in result["members"]]
assert serials == ["SN-A", "SN-B", "SN-C"]
def test_members_sorted_by_position(self, mock_server):
"""Members returned in position order regardless of API order."""
from netbox_librenms_plugin.import_utils.virtual_chassis import detect_virtual_chassis_from_inventory
api = _make_api(mock_server.url)
device_id = 11
mock_server.device_info_response(device_id=device_id, hostname="sw-stack-2")
root_items = [_stack_root(index=5)]
# Deliberately out of order: 3, 1, 2
member_items = [
_chassis(301, "SN-3", position=3),
_chassis(101, "SN-1", position=1),
_chassis(201, "SN-2", position=2),
]
mock_server.vc_inventory_callable(device_id, root_items, {5: member_items})
with patch(
"netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern",
return_value="{master}-m{position}",
):
result = detect_virtual_chassis_from_inventory(api, device_id)
assert result is not None
positions = [m["position"] for m in result["members"]]
assert positions == [1, 2, 3]
def test_position_zero_falls_back_to_idx_plus_one(self, mock_server):
"""position=0 in entPhysicalParentRelPos → fallback to idx+1 (never 0)."""
from netbox_librenms_plugin.import_utils.virtual_chassis import detect_virtual_chassis_from_inventory
api = _make_api(mock_server.url)
device_id = 12
mock_server.device_info_response(device_id=device_id, hostname="sw-stack-3")
root_items = [_stack_root(index=1)]
member_items = [
_chassis(100, "SN-X", position=0), # 0 → fallback to idx+1=1
_chassis(200, "SN-Y", position=0), # 0 → fallback to idx+1=2
]
mock_server.vc_inventory_callable(device_id, root_items, {1: member_items})
with patch(
"netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern",
return_value="{master}-m{position}",
):
result = detect_virtual_chassis_from_inventory(api, device_id)
assert result is not None
positions = [m["position"] for m in result["members"]]
# Both had position=0, so they fall back to idx+1: positions [1, 2]
assert all(p >= 1 for p in positions)
def test_member_fields_extracted_correctly(self, mock_server):
"""serial, model, name, description all extracted from chassis entries."""
from netbox_librenms_plugin.import_utils.virtual_chassis import detect_virtual_chassis_from_inventory
api = _make_api(mock_server.url)
device_id = 13
mock_server.device_info_response(device_id=device_id, hostname="sw-stack-4")
root_items = [_stack_root(index=1)]
member_items = [
{
"entPhysicalIndex": 100,
"entPhysicalClass": "chassis",
"entPhysicalSerialNum": "SERIAL-ABC",
"entPhysicalModelName": "WS-C3750X-48P",
"entPhysicalName": "Slot 1",
"entPhysicalDescr": "48-port PoE switch",
"entPhysicalParentRelPos": 1,
},
{
"entPhysicalIndex": 200,
"entPhysicalClass": "chassis",
"entPhysicalSerialNum": "SERIAL-DEF",
"entPhysicalModelName": "WS-C3750X-24T",
"entPhysicalName": "Slot 2",
"entPhysicalDescr": "24-port switch",
"entPhysicalParentRelPos": 2,
},
]
mock_server.vc_inventory_callable(device_id, root_items, {1: member_items})
with patch(
"netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern",
return_value="{master}-m{position}",
):
result = detect_virtual_chassis_from_inventory(api, device_id)
assert result is not None
m1, m2 = result["members"]
assert m1["serial"] == "SERIAL-ABC"
assert m1["model"] == "WS-C3750X-48P"
assert m1["name"] == "Slot 1"
assert m1["description"] == "48-port PoE switch"
assert m2["serial"] == "SERIAL-DEF"
def test_suggested_name_uses_master_sysname(self, mock_server):
"""suggested_name generated from master device sysName."""
from netbox_librenms_plugin.import_utils.virtual_chassis import detect_virtual_chassis_from_inventory
api = _make_api(mock_server.url)
device_id = 14
mock_server.device_info_response(device_id=device_id, hostname="sw-master", serial="MASTER01")
root_items = [_stack_root(index=1)]
member_items = [
_chassis(100, "SN-1", position=1),
_chassis(200, "SN-2", position=2),
]
mock_server.vc_inventory_callable(device_id, root_items, {1: member_items})
with patch(
"netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern",
return_value="{master}-m{position}",
):
result = detect_virtual_chassis_from_inventory(api, device_id)
assert result is not None
# Members should have non-empty suggested names
for member in result["members"]:
assert "suggested_name" in member
assert member["suggested_name"] # non-empty
class TestDetectVCJuniperStyle:
"""Juniper-style: root has chassis-class entry; children are chassis members."""
def test_two_member_vc(self, mock_server):
"""2 chassis members under a chassis root → is_stack=True, member_count=2."""
from netbox_librenms_plugin.import_utils.virtual_chassis import detect_virtual_chassis_from_inventory
api = _make_api(mock_server.url)
device_id = 20
mock_server.device_info_response(device_id=device_id, hostname="vc-switch")
# Root: a chassis entry (not stack)
root_items = [
{
"entPhysicalIndex": 10,
"entPhysicalClass": "chassis",
"entPhysicalSerialNum": "",
"entPhysicalModelName": "",
"entPhysicalName": "Virtual Chassis",
"entPhysicalDescr": "EX4300 Virtual Chassis",
"entPhysicalContainedIn": 0,
}
]
member_items = [
_chassis(100, "JN-SN-1", position=0), # Juniper uses position=0,1 (1-based after fallback)
_chassis(200, "JN-SN-2", position=1),
]
mock_server.vc_inventory_callable(device_id, root_items, {10: member_items})
with patch(
"netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern",
return_value="{master}-m{position}",
):
result = detect_virtual_chassis_from_inventory(api, device_id)
assert result is not None
assert result["is_stack"] is True
assert result["member_count"] == 2
class TestDetectVCStackPreferredOverChassis:
"""When root has both stack and chassis entries, stack index takes priority."""
def test_stack_index_used_not_chassis(self, mock_server):
from netbox_librenms_plugin.import_utils.virtual_chassis import detect_virtual_chassis_from_inventory
api = _make_api(mock_server.url)
device_id = 30
mock_server.device_info_response(device_id=device_id, hostname="sw-mixed")
# Root has BOTH stack (index=5) and chassis (index=6)
root_items = [
{
"entPhysicalIndex": 5,
"entPhysicalClass": "stack",
"entPhysicalName": "Stack-0",
"entPhysicalSerialNum": "",
"entPhysicalModelName": "",
"entPhysicalDescr": "",
"entPhysicalContainedIn": 0,
},
{
"entPhysicalIndex": 6,
"entPhysicalClass": "chassis",
"entPhysicalName": "Chassis-0",
"entPhysicalSerialNum": "",
"entPhysicalModelName": "",
"entPhysicalDescr": "",
"entPhysicalContainedIn": 0,
},
]
# Stack index=5 has 2 members, chassis index=6 has 0
children = {
5: [_chassis(100, "SN-1", position=1), _chassis(200, "SN-2", position=2)],
6: [], # chassis has no children
}
mock_server.vc_inventory_callable(device_id, root_items, children)
with patch(
"netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern",
return_value="{master}-m{position}",
):
result = detect_virtual_chassis_from_inventory(api, device_id)
# Must have detected 2 members (via stack index), not 0 (via chassis index)
assert result is not None
assert result["member_count"] == 2
class TestDetectVCSingleDevice:
"""Non-stack device: only 1 chassis child → returns None."""
def test_single_chassis_child_returns_none(self, mock_server):
from netbox_librenms_plugin.import_utils.virtual_chassis import detect_virtual_chassis_from_inventory
api = _make_api(mock_server.url)
device_id = 40
mock_server.device_info_response(device_id=device_id, hostname="single-sw")
root_items = [_stack_root(index=1)]
# Only 1 chassis child → not a VC
member_items = [_chassis(100, "SN-ONLY", position=1)]
mock_server.vc_inventory_callable(device_id, root_items, {1: member_items})
with patch(
"netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern",
return_value="{master}-m{position}",
):
result = detect_virtual_chassis_from_inventory(api, device_id)
assert result is None
def test_no_stack_or_chassis_root_returns_none(self, mock_server):
"""Root has only non-stack/chassis entries → returns None."""
from netbox_librenms_plugin.import_utils.virtual_chassis import detect_virtual_chassis_from_inventory
api = _make_api(mock_server.url)
device_id = 41
mock_server.device_info_response(device_id=device_id, hostname="plain-router")
root_items = [
{
"entPhysicalIndex": 1,
"entPhysicalClass": "module",
"entPhysicalName": "Main Module",
"entPhysicalSerialNum": "SN1",
"entPhysicalModelName": "ASR1001-X",
"entPhysicalDescr": "ASR1001-X",
"entPhysicalContainedIn": 0,
}
]
# Register root-only, no children needed
mock_server.vc_inventory_callable(device_id, root_items, {})
with patch(
"netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern",
return_value="{master}-m{position}",
):
result = detect_virtual_chassis_from_inventory(api, device_id)
assert result is None
class TestDetectVCEdgeCases:
"""API errors and empty responses."""
def test_empty_root_inventory_returns_none(self, mock_server):
from netbox_librenms_plugin.import_utils.virtual_chassis import detect_virtual_chassis_from_inventory
api = _make_api(mock_server.url)
device_id = 50
mock_server.device_info_response(device_id=device_id, hostname="empty-sw")
mock_server.vc_inventory_callable(device_id, [], {}) # empty root
with patch(
"netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern",
return_value="{master}-m{position}",
):
result = detect_virtual_chassis_from_inventory(api, device_id)
assert result is None
def test_api_error_on_root_returns_none(self, mock_server):
"""500 error on root inventory → returns None."""
from netbox_librenms_plugin.import_utils.virtual_chassis import detect_virtual_chassis_from_inventory
api = _make_api(mock_server.url)
device_id = 51
mock_server.device_info_response(device_id=device_id, hostname="error-sw")
# Register 500 for inventory calls
mock_server.register(f"/api/v0/inventory/{device_id}", {"status": "error"}, status=500)
with patch(
"netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern",
return_value="{master}-m{position}",
):
result = detect_virtual_chassis_from_inventory(api, device_id)
assert result is None
def test_empty_serial_included_in_members(self, mock_server):
"""Members with empty entPhysicalSerialNum are included, not skipped."""
from netbox_librenms_plugin.import_utils.virtual_chassis import detect_virtual_chassis_from_inventory
api = _make_api(mock_server.url)
device_id = 52
mock_server.device_info_response(device_id=device_id, hostname="nosn-sw")
root_items = [_stack_root(index=1)]
member_items = [
_chassis(100, "", position=1), # empty serial
_chassis(200, "SN-B", position=2),
]
mock_server.vc_inventory_callable(device_id, root_items, {1: member_items})
with patch(
"netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern",
return_value="{master}-m{position}",
):
result = detect_virtual_chassis_from_inventory(api, device_id)
assert result is not None
assert result["member_count"] == 2
assert result["members"][0]["serial"] == "" # empty is preserved
def test_device_info_failure_still_detects_vc(self, mock_server):
"""get_device_info() returning False → detection still works, suggested_name uses fallback."""
from netbox_librenms_plugin.import_utils.virtual_chassis import detect_virtual_chassis_from_inventory
api = _make_api(mock_server.url)
device_id = 53
# No device_info registered → 404
root_items = [_stack_root(index=1)]
member_items = [
_chassis(100, "SN-1", position=1),
_chassis(200, "SN-2", position=2),
]
mock_server.vc_inventory_callable(device_id, root_items, {1: member_items})
with patch(
"netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern",
return_value="{master}-m{position}",
):
result = detect_virtual_chassis_from_inventory(api, device_id)
# Should still detect VC even without device info
assert result is not None
assert result["member_count"] == 2
# Without master name, suggested_name falls back to "Member-{position}"
for member in result["members"]:
assert member["suggested_name"].startswith("Member-")
class TestGetVCDataHTTP:
"""get_virtual_chassis_data() integrating with mock HTTP server and patched cache."""
def test_cache_miss_fetches_via_http(self, mock_server):
"""Cache miss triggers detect_virtual_chassis_from_inventory via HTTP."""
from netbox_librenms_plugin.import_utils.virtual_chassis import get_virtual_chassis_data
api = _make_api(mock_server.url)
device_id = 60
mock_server.device_info_response(device_id=device_id, hostname="cached-sw")
root_items = [_stack_root(index=1)]
member_items = [
_chassis(100, "SN-1", position=1),
_chassis(200, "SN-2", position=2),
]
mock_server.vc_inventory_callable(device_id, root_items, {1: member_items})
with patch("netbox_librenms_plugin.import_utils.virtual_chassis.cache") as mock_cache:
mock_cache.get.return_value = None # cache miss
with patch(
"netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern",
return_value="{master}-m{position}",
):
result = get_virtual_chassis_data(api, device_id)
# Should have called cache.set to store result
assert mock_cache.set.called
assert result is not None
assert result["is_stack"] is True
assert result["member_count"] == 2
def test_cache_hit_returns_without_http(self, mock_server):
"""Cache hit returns immediately without making any HTTP calls."""
from netbox_librenms_plugin.import_utils.virtual_chassis import get_virtual_chassis_data
api = _make_api(mock_server.url)
device_id = 61
# Include detection_error to match what _clone_virtual_chassis_data adds
cached_data = {"is_stack": True, "member_count": 3, "members": [], "detection_error": None}
with patch("netbox_librenms_plugin.import_utils.virtual_chassis.cache") as mock_cache:
mock_cache.get.return_value = cached_data
result = get_virtual_chassis_data(api, device_id)
# cache.set should NOT be called (no new fetch)
assert not mock_cache.set.called
assert result["is_stack"] is True
assert result["member_count"] == 3
def test_force_refresh_fetches_even_if_cached(self, mock_server):
"""force_refresh=True bypasses cache and fetches from HTTP."""
from netbox_librenms_plugin.import_utils.virtual_chassis import get_virtual_chassis_data
api = _make_api(mock_server.url)
device_id = 62
mock_server.device_info_response(device_id=device_id, hostname="refresh-sw")
root_items = [_stack_root(index=1)]
member_items = [
_chassis(100, "SN-A", position=1),
_chassis(200, "SN-B", position=2),
]
mock_server.vc_inventory_callable(device_id, root_items, {1: member_items})
# Even with cached data, force_refresh should hit the API
old_cached = {"is_stack": True, "member_count": 1, "members": [{"serial": "OLD"}], "detection_error": None}
with patch("netbox_librenms_plugin.import_utils.virtual_chassis.cache") as mock_cache:
mock_cache.get.return_value = old_cached
with patch(
"netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern",
return_value="{master}-m{position}",
):
result = get_virtual_chassis_data(api, device_id, force_refresh=True)
# Should have fetched fresh data, not used old_cached
assert result is not None
assert result["member_count"] == 2 # new data, not old
def test_non_vc_device_returns_empty_dict(self, mock_server):
"""Single device (not VC) → detect returns None → get_virtual_chassis_data returns empty."""
from netbox_librenms_plugin.import_utils.virtual_chassis import get_virtual_chassis_data
api = _make_api(mock_server.url)
device_id = 63
mock_server.device_info_response(device_id=device_id, hostname="single-sw")
root_items = [_stack_root(index=1)]
member_items = [_chassis(100, "SN-ONLY", position=1)] # only 1 → not VC
mock_server.vc_inventory_callable(device_id, root_items, {1: member_items})
with patch("netbox_librenms_plugin.import_utils.virtual_chassis.cache") as mock_cache:
mock_cache.get.return_value = None
with patch(
"netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern",
return_value="{master}-m{position}",
):
result = get_virtual_chassis_data(api, device_id)
assert result is not None
assert result.get("is_stack") is False
class TestPrefetchVCHTTP:
"""prefetch_vc_data_for_devices() fetches multiple devices in batch."""
def test_prefetch_multiple_vc_devices(self, mock_server):
"""Three VC devices → cache populated for all three."""
from netbox_librenms_plugin.import_utils.virtual_chassis import prefetch_vc_data_for_devices
api = _make_api(mock_server.url)
for dev_id, hostname in [(70, "sw-70"), (71, "sw-71"), (72, "sw-72")]:
mock_server.device_info_response(device_id=dev_id, hostname=hostname)
root_items = [_stack_root(index=1)]
member_items = [
_chassis(100, f"SN-{dev_id}-1", position=1),
_chassis(200, f"SN-{dev_id}-2", position=2),
]
mock_server.vc_inventory_callable(dev_id, root_items, {1: member_items})
cache_store = {}
def mock_cache_set(key, val, timeout=None):
cache_store[key] = val
def mock_cache_get(key):
return cache_store.get(key)
with patch("netbox_librenms_plugin.import_utils.virtual_chassis.cache") as mock_cache:
mock_cache.get.side_effect = mock_cache_get
mock_cache.set.side_effect = mock_cache_set
with patch(
"netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern",
return_value="{master}-m{position}",
):
prefetch_vc_data_for_devices(api, [70, 71, 72])
# Cache should have entries for all 3 VC devices
assert len(cache_store) >= 3
def test_prefetch_mix_vc_and_single(self, mock_server):
"""Mix of VC and single devices → VC is cached, single is processed without error."""
from netbox_librenms_plugin.import_utils.virtual_chassis import prefetch_vc_data_for_devices
api = _make_api(mock_server.url)
# Device 80 = 2-member VC
mock_server.device_info_response(device_id=80, hostname="sw-stack")
root_items_80 = [_stack_root(index=1)]
member_items_80 = [_chassis(100, "SN-80-1", position=1), _chassis(200, "SN-80-2", position=2)]
mock_server.vc_inventory_callable(80, root_items_80, {1: member_items_80})
# Device 81 = single (no VC)
mock_server.device_info_response(device_id=81, hostname="sw-single")
root_items_81 = [_stack_root(index=1)]
member_items_81 = [_chassis(100, "SN-81", position=1)] # only 1 → not VC
mock_server.vc_inventory_callable(81, root_items_81, {1: member_items_81})
cache_store = {}
def mock_cache_set(key, val, timeout=None):
cache_store[key] = val
def mock_cache_get(key):
return cache_store.get(key)
with patch("netbox_librenms_plugin.import_utils.virtual_chassis.cache") as mock_cache:
mock_cache.get.side_effect = mock_cache_get
mock_cache.set.side_effect = mock_cache_set
with patch(
"netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern",
return_value="{master}-m{position}",
):
prefetch_vc_data_for_devices(api, [80, 81])
# Both the VC device (80) and the non-VC device (81) should be cached.
# Non-VC devices get an empty_virtual_chassis_data() cached so prefetch
# suppresses repeated API hits on subsequent renders.
assert len(cache_store) == 2
class TestNegativeVCCaching:
"""Negative results (non-stack, API errors) must be cached to suppress repeated hits."""
def test_non_vc_device_result_is_cached(self, mock_server):
"""Single device (not a stack) → detect returns None → empty result cached."""
from netbox_librenms_plugin.import_utils.virtual_chassis import get_virtual_chassis_data
api = _make_api(mock_server.url)
device_id = 200
mock_server.device_info_response(device_id=device_id, hostname="single-sw")
root_items = [_stack_root(index=1)]
member_items = [_chassis(100, "SN-ONLY", position=1)] # 1 chassis only → not VC
mock_server.vc_inventory_callable(device_id, root_items, {1: member_items})
cache_store = {}
def mock_cache_set(key, val, timeout=None):
cache_store[key] = val
def mock_cache_get(key):
return cache_store.get(key)
with patch("netbox_librenms_plugin.import_utils.virtual_chassis.cache") as mock_cache:
mock_cache.get.side_effect = mock_cache_get
mock_cache.set.side_effect = mock_cache_set
with patch(
"netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern",
return_value="{master}-m{position}",
):
result = get_virtual_chassis_data(api, device_id)
assert result is not None
assert result.get("is_stack") is False
assert result.get("member_count") == 0
# The empty result must have been written to cache so a second call is a hit.
assert len(cache_store) == 1
cached = list(cache_store.values())[0]
assert cached.get("is_stack") is False
def test_api_error_result_is_cached(self, mock_server):
"""API 500 on inventory → detect returns None → empty result still cached."""
from netbox_librenms_plugin.import_utils.virtual_chassis import get_virtual_chassis_data
api = _make_api(mock_server.url)
device_id = 201
# Register a 500 for the root inventory call
mock_server.routes[f"/api/v0/inventory/{device_id}"] = (500, {"status": "error", "message": "internal"})
cache_store = {}
def mock_cache_set(key, val, timeout=None):
cache_store[key] = val
def mock_cache_get(key):
return cache_store.get(key)
with patch("netbox_librenms_plugin.import_utils.virtual_chassis.cache") as mock_cache:
mock_cache.get.side_effect = mock_cache_get
mock_cache.set.side_effect = mock_cache_set
with patch(
"netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern",
return_value="{master}-m{position}",
):
result = get_virtual_chassis_data(api, device_id)
assert result is not None
assert result.get("is_stack") is False
# Even API failures get cached to suppress repeated hits until TTL expires.
assert len(cache_store) == 1
def test_force_refresh_bypasses_negative_cache(self, mock_server):
"""force_refresh=True re-fetches even when a negative result is cached."""
from netbox_librenms_plugin.import_utils.virtual_chassis import get_virtual_chassis_data
api = _make_api(mock_server.url)
device_id = 202
mock_server.device_info_response(device_id=device_id, hostname="single-sw-202")
root_items = [_stack_root(index=1)]
member_items = [_chassis(100, "SN-202", position=1)] # 1 chassis → not VC
mock_server.vc_inventory_callable(device_id, root_items, {1: member_items})
# Pre-populate cache with an empty (negative) result.
empty_cached = {"is_stack": False, "member_count": 0, "members": [], "detection_error": None}
call_count = {"n": 0}
def mock_cache_get(key):
return empty_cached # always returns cached negative
cache_set_calls = []
def mock_cache_set(key, val, timeout=None):
cache_set_calls.append((key, val))
call_count["n"] += 1
with patch("netbox_librenms_plugin.import_utils.virtual_chassis.cache") as mock_cache:
mock_cache.get.side_effect = mock_cache_get
mock_cache.set.side_effect = mock_cache_set
with patch(
"netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern",
return_value="{master}-m{position}",
):
# Normal call — should use cache, NOT call set again
result_cached = get_virtual_chassis_data(api, device_id)
assert call_count["n"] == 0 # no new set; cache hit returned
# force_refresh=True — must bypass cache and re-fetch + re-cache
result_fresh = get_virtual_chassis_data(api, device_id, force_refresh=True)
assert call_count["n"] == 1 # set called once for the re-fetch
assert result_cached.get("is_stack") is False
assert result_fresh.get("is_stack") is False
class TestVCPortFetch:
"""Port fetching for VC master: port names with VC member suffixes."""
def test_ports_with_vc_suffixes_returned_as_is(self, mock_server):
"""Port names like Gi1/0/1 and Gi2/0/1 preserved from API response."""
api = _make_api(mock_server.url)
vc_ports = [
{
"port_id": 101,
"ifName": "GigabitEthernet1/0/1",
"ifDescr": "GigabitEthernet1/0/1",
"ifType": "ethernetCsmacd",
"ifSpeed": 1_000_000_000,
"ifAdminStatus": "up",
"ifAlias": "uplink-m1",
"ifPhysAddress": "aa:bb:cc:dd:ee:01",
"ifMtu": 1500,
"ifVlan": 1,
"ifTrunk": 0,
},
{
"port_id": 201,
"ifName": "GigabitEthernet2/0/1",
"ifDescr": "GigabitEthernet2/0/1",
"ifType": "ethernetCsmacd",
"ifSpeed": 1_000_000_000,
"ifAdminStatus": "up",
"ifAlias": "uplink-m2",
"ifPhysAddress": "aa:bb:cc:dd:ee:02",
"ifMtu": 1500,
"ifVlan": 1,
"ifTrunk": 0,
},
{
"port_id": 301,
"ifName": "GigabitEthernet1/0/2",
"ifDescr": "GigabitEthernet1/0/2",
"ifType": "ethernetCsmacd",
"ifSpeed": 1_000_000_000,
"ifAdminStatus": "down",
"ifAlias": "",
"ifPhysAddress": "aa:bb:cc:dd:ee:03",
"ifMtu": 1500,
"ifVlan": 10,
"ifTrunk": 0,
},
]
mock_server.ports_response(device_id=90, ports=vc_ports)
ok, data = api.get_ports(90)
assert ok is True
names = [p["ifName"] for p in data["ports"]]
assert "GigabitEthernet1/0/1" in names
assert "GigabitEthernet2/0/1" in names
assert "GigabitEthernet1/0/2" in names
def test_all_port_fields_preserved(self, mock_server):
"""ifName, ifDescr, ifAlias, ifSpeed all preserved from LibreNMS response."""
api = _make_api(mock_server.url)
ports_data = [
{
"port_id": 111,
"ifName": "GigabitEthernet1/0/1",
"ifDescr": "GigabitEthernet1/0/1",
"ifType": "ethernetCsmacd",
"ifSpeed": 1_000_000_000,
"ifAdminStatus": "up",
"ifAlias": "server-link",
"ifPhysAddress": "aa:bb:cc:00:00:01",
"ifMtu": 9000,
"ifVlan": 100,
"ifTrunk": 1,
},
]
mock_server.ports_response(device_id=91, ports=ports_data)
ok, data = api.get_ports(91)
assert ok is True
port = data["ports"][0]
assert port["ifName"] == "GigabitEthernet1/0/1"
assert port["ifAlias"] == "server-link"
assert port["ifSpeed"] == 1_000_000_000
assert port["ifMtu"] == 9000

View File

@@ -0,0 +1,563 @@
"""
Tests for interface VLAN sync functionality (Phase 2).
Tests cover:
- VlanAssignmentMixin methods
- Port VLAN enrichment
- VLAN sync action
"""
from unittest.mock import MagicMock, patch
# Import the autouse fixture from helpers
pytest_plugins = ["netbox_librenms_plugin.tests.test_librenms_api_helpers"]
class TestVlanAssignmentMixin:
"""Tests for VlanAssignmentMixin methods."""
def test_get_vlan_groups_for_device_includes_site_scoped(self, mock_librenms_config):
"""Test that VLAN groups scoped to device's site are included."""
from netbox_librenms_plugin.views.mixins import VlanAssignmentMixin
mixin = VlanAssignmentMixin()
# Create mock device with site
mock_device = MagicMock()
mock_device.site = MagicMock()
mock_device.site.pk = 1
mock_device.site.region = None
mock_device.site.group = None
mock_device.location = None
mock_device.rack = None
# Mock the VLAN group query
mock_site_group = MagicMock()
mock_site_group.name = "Site VLANs"
mock_site_group.pk = 10
with patch.object(mixin, "_get_vlan_groups_for_scope") as mock_get_scope:
mock_get_scope.return_value = [mock_site_group]
with patch("ipam.models.VLANGroup") as mock_vlan_group_class:
mock_vlan_group_class.objects.filter.return_value = []
mixin.get_vlan_groups_for_device(mock_device)
# Verify site scope was queried
assert mock_get_scope.called
def test_get_vlan_groups_for_device_includes_global(self, mock_librenms_config):
"""Test that global VLAN groups (no scope) are included."""
from netbox_librenms_plugin.views.mixins import VlanAssignmentMixin
mixin = VlanAssignmentMixin()
# Create mock device with no location context
mock_device = MagicMock()
mock_device.site = None
mock_device.location = None
mock_device.rack = None
with patch.object(mixin, "_get_vlan_groups_for_scope") as mock_get_scope:
mock_get_scope.return_value = []
with patch("ipam.models.VLANGroup") as mock_vlan_group_class:
mock_global_group = MagicMock()
mock_global_group.name = "Global VLANs"
mock_global_group.pk = 20
mock_vlan_group_class.objects.filter.return_value = [mock_global_group]
mixin.get_vlan_groups_for_device(mock_device)
# Verify global scope was queried
mock_vlan_group_class.objects.filter.assert_called_with(scope_type__isnull=True)
def test_select_most_specific_group_prefers_rack(self, mock_librenms_config):
"""Test that rack-scoped groups are preferred over site-scoped."""
from netbox_librenms_plugin.views.mixins import VlanAssignmentMixin
mixin = VlanAssignmentMixin()
# Create mock device with rack
mock_device = MagicMock()
mock_device.rack = MagicMock()
mock_device.rack.pk = 1
mock_device.site = MagicMock()
mock_device.site.pk = 2
mock_device.site.region = None
mock_device.site.group = None
mock_device.location = None
# Create mock groups with different scopes
mock_rack_group = MagicMock()
mock_rack_group.scope_type = MagicMock()
mock_rack_group.scope_type.pk = 100 # Rack content type
mock_rack_group.scope_id = 1
mock_site_group = MagicMock()
mock_site_group.scope_type = MagicMock()
mock_site_group.scope_type.pk = 101 # Site content type
mock_site_group.scope_id = 2
with patch("django.contrib.contenttypes.models.ContentType") as mock_ct:
# Mock ContentType lookups
mock_ct.objects.get_for_model.side_effect = lambda model: MagicMock(pk=100 if "Rack" in str(model) else 101)
result = mixin._select_most_specific_group([mock_rack_group, mock_site_group], mock_device)
# Rack-scoped should be preferred
assert result == mock_rack_group
def test_select_most_specific_group_returns_none_for_ambiguous(self, mock_librenms_config):
"""Test that None is returned when multiple groups have same priority."""
from netbox_librenms_plugin.views.mixins import VlanAssignmentMixin
mixin = VlanAssignmentMixin()
# Create mock device
mock_device = MagicMock()
mock_device.site = MagicMock()
mock_device.site.pk = 1
mock_device.site.region = None
mock_device.site.group = None
mock_device.rack = None
mock_device.location = None
# Create two groups with same scope (both site-scoped to same site)
mock_group1 = MagicMock()
mock_group1.scope_type = MagicMock()
mock_group1.scope_type.pk = 101
mock_group1.scope_id = 1
mock_group2 = MagicMock()
mock_group2.scope_type = MagicMock()
mock_group2.scope_type.pk = 101
mock_group2.scope_id = 1
with patch("django.contrib.contenttypes.models.ContentType") as mock_ct:
mock_ct.objects.get_for_model.return_value = MagicMock(pk=101)
result = mixin._select_most_specific_group([mock_group1, mock_group2], mock_device)
# Ambiguous - should return None
assert result is None
def test_get_ancestors_returns_hierarchy(self, mock_librenms_config):
"""Test that _get_ancestors returns full parent chain."""
from netbox_librenms_plugin.views.mixins import VlanAssignmentMixin
mixin = VlanAssignmentMixin()
# Create mock location hierarchy
mock_grandparent = MagicMock()
mock_grandparent.parent = None
mock_parent = MagicMock()
mock_parent.parent = mock_grandparent
mock_location = MagicMock()
mock_location.parent = mock_parent
ancestors = mixin._get_ancestors(mock_location)
assert len(ancestors) == 3
assert ancestors[0] == mock_location
assert ancestors[1] == mock_parent
assert ancestors[2] == mock_grandparent
def test_find_vlan_in_group_prefers_specified_group(self, mock_librenms_config):
"""Test that _find_vlan_in_group prefers the specified group."""
from netbox_librenms_plugin.views.mixins import VlanAssignmentMixin
mixin = VlanAssignmentMixin()
mock_vlan_in_group = MagicMock()
mock_vlan_global = MagicMock()
lookup_maps = {
"vid_group_to_vlan": {
(100, 5): mock_vlan_in_group,
(100, None): mock_vlan_global,
},
"vid_to_vlans": {
100: [mock_vlan_in_group, mock_vlan_global],
},
}
result = mixin._find_vlan_in_group(100, 5, lookup_maps)
assert result == mock_vlan_in_group
def test_find_vlan_in_group_falls_back_to_global(self, mock_librenms_config):
"""Test that _find_vlan_in_group falls back to global VLAN."""
from netbox_librenms_plugin.views.mixins import VlanAssignmentMixin
mixin = VlanAssignmentMixin()
mock_vlan_global = MagicMock()
lookup_maps = {
"vid_group_to_vlan": {
(100, None): mock_vlan_global,
},
"vid_to_vlans": {
100: [mock_vlan_global],
},
}
# Request group 5 which doesn't have VLAN 100
result = mixin._find_vlan_in_group(100, 5, lookup_maps)
assert result == mock_vlan_global
def test_find_vlan_in_group_returns_none_if_not_found(self, mock_librenms_config):
"""Test that _find_vlan_in_group returns None if VLAN not found."""
from netbox_librenms_plugin.views.mixins import VlanAssignmentMixin
mixin = VlanAssignmentMixin()
lookup_maps = {
"vid_group_to_vlan": {},
"vid_to_vlans": {},
}
result = mixin._find_vlan_in_group(999, None, lookup_maps)
assert result is None
class TestPortVlanEnrichment:
"""Tests for port VLAN data enrichment."""
pytest_plugins = ["tests.test_librenms_api_helpers"]
@patch("requests.get")
def test_parse_port_vlan_data_access_port(self, mock_get, mock_librenms_config):
"""Test parsing access port VLAN data."""
from netbox_librenms_plugin.librenms_api import LibreNMSAPI
api = LibreNMSAPI(server_key="default")
port_data = {
"port_id": 1234,
"ifName": "Gi1/0/1",
"ifDescr": "GigabitEthernet1/0/1",
"ifVlan": "100",
"ifTrunk": None,
}
result = api.parse_port_vlan_data(port_data, "ifName")
assert result["port_id"] == 1234
assert result["interface_name"] == "Gi1/0/1"
assert result["mode"] == "access"
assert result["untagged_vlan"] == 100
assert result["tagged_vlans"] == []
@patch("requests.get")
def test_parse_port_vlan_data_trunk_port(self, mock_get, mock_librenms_config):
"""Test parsing trunk port VLAN data."""
from netbox_librenms_plugin.librenms_api import LibreNMSAPI
api = LibreNMSAPI(server_key="default")
port_data = {
"port_id": 5678,
"ifName": "Te1/1/1",
"ifDescr": "TenGigabitEthernet1/1/1",
"ifVlan": "90",
"ifTrunk": "dot1Q",
"vlans": [
{"vlan": 90, "untagged": 1, "state": "unknown"},
{"vlan": 50, "untagged": 0, "state": "forwarding"},
{"vlan": 60, "untagged": 0, "state": "forwarding"},
],
}
result = api.parse_port_vlan_data(port_data, "ifName")
assert result["port_id"] == 5678
assert result["interface_name"] == "Te1/1/1"
assert result["mode"] == "tagged"
assert result["untagged_vlan"] == 90
assert sorted(result["tagged_vlans"]) == [50, 60]
@patch("requests.get")
def test_parse_port_vlan_data_uses_interface_name_field(self, mock_get, mock_librenms_config):
"""Test that parse_port_vlan_data respects interface_name_field parameter."""
from netbox_librenms_plugin.librenms_api import LibreNMSAPI
api = LibreNMSAPI(server_key="default")
port_data = {
"port_id": 1234,
"ifName": "Gi1/0/1",
"ifDescr": "GigabitEthernet1/0/1",
"ifVlan": "100",
"ifTrunk": None,
}
result = api.parse_port_vlan_data(port_data, "ifDescr")
assert result["interface_name"] == "GigabitEthernet1/0/1"
class TestInterfaceVlanSync:
"""Tests for interface VLAN sync action."""
pytest_plugins = ["tests.test_librenms_api_helpers"]
def test_update_interface_vlan_assignment_access_mode(self, mock_librenms_config):
"""Test that access mode is set correctly for untagged-only ports."""
from netbox_librenms_plugin.views.mixins import VlanAssignmentMixin
mixin = VlanAssignmentMixin()
mock_interface = MagicMock()
mock_interface.tagged_vlans = MagicMock()
mock_vlan = MagicMock()
mock_vlan.vid = 100
lookup_maps = {
"vid_group_to_vlan": {(100, None): mock_vlan},
"vid_to_vlans": {100: [mock_vlan]},
}
vlan_data = {
"untagged_vlan": 100,
"tagged_vlans": [],
}
mixin._update_interface_vlan_assignment(mock_interface, vlan_data, None, lookup_maps)
assert mock_interface.mode == "access"
assert mock_interface.untagged_vlan == mock_vlan
mock_interface.tagged_vlans.clear.assert_called_once()
def test_update_interface_vlan_assignment_tagged_mode(self, mock_librenms_config):
"""Test that tagged mode is set for trunk ports."""
from netbox_librenms_plugin.views.mixins import VlanAssignmentMixin
mixin = VlanAssignmentMixin()
mock_interface = MagicMock()
mock_interface.tagged_vlans = MagicMock()
mock_vlan_100 = MagicMock()
mock_vlan_100.vid = 100
mock_vlan_200 = MagicMock()
mock_vlan_200.vid = 200
mock_vlan_300 = MagicMock()
mock_vlan_300.vid = 300
lookup_maps = {
"vid_group_to_vlan": {
(100, None): mock_vlan_100,
(200, None): mock_vlan_200,
(300, None): mock_vlan_300,
},
"vid_to_vlans": {
100: [mock_vlan_100],
200: [mock_vlan_200],
300: [mock_vlan_300],
},
}
vlan_data = {
"untagged_vlan": 100,
"tagged_vlans": [200, 300],
}
mixin._update_interface_vlan_assignment(mock_interface, vlan_data, None, lookup_maps)
assert mock_interface.mode == "tagged"
assert mock_interface.untagged_vlan == mock_vlan_100
mock_interface.tagged_vlans.set.assert_called_once_with([mock_vlan_200, mock_vlan_300])
def test_update_interface_vlan_assignment_missing_vlans(self, mock_librenms_config):
"""Test that missing VLANs are tracked in result."""
from netbox_librenms_plugin.views.mixins import VlanAssignmentMixin
mixin = VlanAssignmentMixin()
mock_interface = MagicMock()
mock_interface.tagged_vlans = MagicMock()
# Empty lookup maps - no VLANs exist in NetBox
lookup_maps = {
"vid_group_to_vlan": {},
"vid_to_vlans": {},
}
vlan_data = {
"untagged_vlan": 100,
"tagged_vlans": [200, 300],
}
result = mixin._update_interface_vlan_assignment(mock_interface, vlan_data, None, lookup_maps)
assert result["missing_vlans"] == [100, 200, 300]
assert mock_interface.untagged_vlan is None
mock_interface.tagged_vlans.set.assert_called_once_with([])
def test_update_interface_vlan_assignment_respects_group_selection(self, mock_librenms_config):
"""Test that VLAN group selection is respected."""
from netbox_librenms_plugin.views.mixins import VlanAssignmentMixin
mixin = VlanAssignmentMixin()
mock_interface = MagicMock()
mock_interface.tagged_vlans = MagicMock()
mock_vlan_group1 = MagicMock()
mock_vlan_group1.vid = 100
mock_vlan_global = MagicMock()
mock_vlan_global.vid = 100
lookup_maps = {
"vid_group_to_vlan": {
(100, 5): mock_vlan_group1,
(100, None): mock_vlan_global,
},
"vid_to_vlans": {
100: [mock_vlan_group1, mock_vlan_global],
},
}
vlan_data = {
"untagged_vlan": 100,
"tagged_vlans": [],
}
# Request VLAN from group 5
mixin._update_interface_vlan_assignment(mock_interface, vlan_data, 5, lookup_maps)
# Should use group-specific VLAN
assert mock_interface.untagged_vlan == mock_vlan_group1
class TestInterfaceCssClassGroupMatching:
"""
Tests for group-aware VLAN CSS class functions in utils.py.
Verifies that VLAN group mismatch (same VID but different group) produces
orange (text-warning) instead of green (text-success).
"""
# -- get_untagged_vlan_css_class --
def test_untagged_vid_match_group_match_returns_green(self, mock_librenms_config):
from netbox_librenms_plugin.utils import get_untagged_vlan_css_class
assert get_untagged_vlan_css_class(60, 60, True, [], group_matches=True) == "text-success"
def test_untagged_vid_match_group_mismatch_returns_orange(self, mock_librenms_config):
from netbox_librenms_plugin.utils import get_untagged_vlan_css_class
assert get_untagged_vlan_css_class(60, 60, True, [], group_matches=False) == "text-warning"
def test_untagged_vid_differs_group_irrelevant(self, mock_librenms_config):
"""Different VIDs -> text-warning regardless of group_matches."""
from netbox_librenms_plugin.utils import get_untagged_vlan_css_class
assert get_untagged_vlan_css_class(60, 100, True, [], group_matches=True) == "text-warning"
def test_untagged_not_in_netbox_ignores_group(self, mock_librenms_config):
from netbox_librenms_plugin.utils import get_untagged_vlan_css_class
assert get_untagged_vlan_css_class(60, 60, False, [], group_matches=True) == "text-danger"
def test_untagged_missing_vlan_ignores_group(self, mock_librenms_config):
from netbox_librenms_plugin.utils import get_untagged_vlan_css_class
assert get_untagged_vlan_css_class(60, 60, True, [60], group_matches=True) == "text-danger"
def test_untagged_no_netbox_vlan_returns_red(self, mock_librenms_config):
from netbox_librenms_plugin.utils import get_untagged_vlan_css_class
assert get_untagged_vlan_css_class(60, None, True, [], group_matches=True) == "text-danger"
def test_untagged_default_group_matches_is_true(self, mock_librenms_config):
"""Without group_matches param, defaults to True (backward compat)."""
from netbox_librenms_plugin.utils import get_untagged_vlan_css_class
assert get_untagged_vlan_css_class(60, 60, True, []) == "text-success"
# -- get_tagged_vlan_css_class --
def test_tagged_vid_present_group_match_returns_green(self, mock_librenms_config):
from netbox_librenms_plugin.utils import get_tagged_vlan_css_class
assert get_tagged_vlan_css_class(60, {60, 100}, True, [], group_matches=True) == "text-success"
def test_tagged_vid_present_group_mismatch_returns_orange(self, mock_librenms_config):
from netbox_librenms_plugin.utils import get_tagged_vlan_css_class
assert get_tagged_vlan_css_class(60, {60, 100}, True, [], group_matches=False) == "text-warning"
def test_tagged_vid_absent_group_irrelevant(self, mock_librenms_config):
from netbox_librenms_plugin.utils import get_tagged_vlan_css_class
assert get_tagged_vlan_css_class(60, {100}, True, [], group_matches=True) == "text-danger"
def test_tagged_not_in_netbox_ignores_group(self, mock_librenms_config):
from netbox_librenms_plugin.utils import get_tagged_vlan_css_class
assert get_tagged_vlan_css_class(60, {60}, False, [], group_matches=True) == "text-danger"
def test_tagged_missing_vlan_ignores_group(self, mock_librenms_config):
from netbox_librenms_plugin.utils import get_tagged_vlan_css_class
assert get_tagged_vlan_css_class(60, {60}, True, [60], group_matches=True) == "text-danger"
def test_tagged_default_group_matches_is_true(self, mock_librenms_config):
"""Without group_matches param, defaults to True (backward compat)."""
from netbox_librenms_plugin.utils import get_tagged_vlan_css_class
assert get_tagged_vlan_css_class(60, {60}, True, []) == "text-success"
# -- check_vlan_group_matches --
def test_check_group_matches_untagged_same_group(self, mock_librenms_config):
from netbox_librenms_plugin.utils import check_vlan_group_matches
assert check_vlan_group_matches("U", 60, 5, 5, {}, 60, set()) is True
def test_check_group_matches_untagged_different_group(self, mock_librenms_config):
from netbox_librenms_plugin.utils import check_vlan_group_matches
assert check_vlan_group_matches("U", 60, 10, 5, {}, 60, set()) is False
def test_check_group_matches_untagged_vid_differs(self, mock_librenms_config):
"""When VIDs don't match, group comparison is irrelevant -> True."""
from netbox_librenms_plugin.utils import check_vlan_group_matches
assert check_vlan_group_matches("U", 60, 10, 5, {}, 100, set()) is True
def test_check_group_matches_tagged_same_group(self, mock_librenms_config):
from netbox_librenms_plugin.utils import check_vlan_group_matches
assert check_vlan_group_matches("T", 60, 5, None, {60: 5}, None, {60}) is True
def test_check_group_matches_tagged_different_group(self, mock_librenms_config):
from netbox_librenms_plugin.utils import check_vlan_group_matches
assert check_vlan_group_matches("T", 60, 10, None, {60: 5}, None, {60}) is False
def test_check_group_matches_tagged_vid_absent(self, mock_librenms_config):
"""When VID is not tagged in NetBox, group comparison irrelevant -> True."""
from netbox_librenms_plugin.utils import check_vlan_group_matches
assert check_vlan_group_matches("T", 60, 10, None, {}, None, set()) is True
def test_check_group_matches_global_to_global(self, mock_librenms_config):
"""Both NetBox VLAN and selected have no group (global) -> match."""
from netbox_librenms_plugin.utils import check_vlan_group_matches
assert check_vlan_group_matches("U", 60, None, None, {}, 60, set()) is True
def test_check_group_matches_global_vs_group(self, mock_librenms_config):
"""NetBox VLAN is global, selected is a specific group -> mismatch."""
from netbox_librenms_plugin.utils import check_vlan_group_matches
assert check_vlan_group_matches("U", 60, 5, None, {}, 60, set()) is False

View File

@@ -0,0 +1,137 @@
"""
Regression tests for SingleIPAddressVerifyView.post().
Covers:
- Cache key uses CacheMixin.get_cache_key() (server-aware) instead of
the old private _get_cache_key() that produced a different format.
- server_key from POST body is threaded into the cache lookup so
non-default servers hit the correct cache entry.
"""
import json
from unittest.mock import MagicMock, patch
import pytest
def _make_view():
"""Create a SingleIPAddressVerifyView instance without database access."""
from netbox_librenms_plugin.views.base.ip_addresses_view import SingleIPAddressVerifyView
view = object.__new__(SingleIPAddressVerifyView)
return view
def _make_request(body_dict):
"""Create a mock POST request with JSON body."""
request = MagicMock()
request.method = "POST"
request.body = json.dumps(body_dict).encode()
return request
def _mock_device(pk=1):
"""Create a mock Device with _meta for cache key generation."""
device = MagicMock()
device.pk = pk
device._meta.model_name = "device"
device.name = "test-device"
device.get_absolute_url.return_value = f"/dcim/devices/{pk}/"
device.interfaces.first.return_value = None
return device
class TestCacheKeyFormat:
"""SingleIPAddressVerifyView must use CacheMixin.get_cache_key()."""
def test_no_private_get_cache_key_method(self):
"""The old _get_cache_key method must not exist on SingleIPAddressVerifyView."""
from netbox_librenms_plugin.views.base.ip_addresses_view import SingleIPAddressVerifyView
assert not hasattr(SingleIPAddressVerifyView, "_get_cache_key"), (
"SingleIPAddressVerifyView still has _get_cache_key; it should use CacheMixin.get_cache_key() instead"
)
def test_cache_key_matches_writer_format(self):
"""The cache key used by post() must match the format used by _prepare_context()."""
view = _make_view()
device = _mock_device(pk=42)
# CacheMixin.get_cache_key produces this format
expected_key = "librenms_ip_addresses_device_42_prod"
assert view.get_cache_key(device, "ip_addresses", "prod") == expected_key
def test_cache_key_default_server(self):
"""Default server key produces the expected cache key format."""
view = _make_view()
device = _mock_device(pk=7)
expected_key = "librenms_ip_addresses_device_7_default"
assert view.get_cache_key(device, "ip_addresses", "default") == expected_key
class TestServerKeyFromPost:
"""server_key from POST body must be used for cache lookup."""
@pytest.fixture(autouse=True)
def _patch_ip_models(self):
"""Patch IPAddress.objects to avoid DB access."""
with patch("netbox_librenms_plugin.views.base.ip_addresses_view.IPAddress") as mock_ip:
mock_ip.objects.filter.return_value.first.return_value = None
yield
def _run_post(self, body, device=None):
"""Execute view.post() with mocks and return the cache key used."""
view = _make_view()
if device is None:
device = _mock_device()
request = _make_request(body)
captured_cache_key = {}
def fake_cache_get(key):
captured_cache_key["key"] = key
return {"ip_addresses": []}
with (
patch(
"netbox_librenms_plugin.views.base.ip_addresses_view.get_object_or_404",
return_value=device,
),
patch("netbox_librenms_plugin.views.base.ip_addresses_view.cache") as mock_cache,
):
mock_cache.get.side_effect = fake_cache_get
view.post(request)
return captured_cache_key.get("key")
def test_server_key_threaded_to_cache_lookup(self):
"""post() must include server_key in the cache key."""
device = _mock_device(pk=5)
key = self._run_post(
{"device_id": 5, "ip_address": "10.0.0.1/24", "server_key": "prod", "object_type": "device"},
device=device,
)
assert key == "librenms_ip_addresses_device_5_prod"
def test_default_server_key_when_missing(self):
"""When server_key is absent from POST, default to 'default'."""
device = _mock_device(pk=5)
key = self._run_post(
{"device_id": 5, "ip_address": "10.0.0.1/24", "object_type": "device"},
device=device,
)
assert key == "librenms_ip_addresses_device_5_default"
def test_null_server_key_falls_back_to_default(self):
"""When server_key is explicitly null, fall back to 'default'."""
device = _mock_device(pk=5)
key = self._run_post(
{"device_id": 5, "ip_address": "10.0.0.1/24", "server_key": None, "object_type": "device"},
device=device,
)
assert key == "librenms_ip_addresses_device_5_default"

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,26 @@
"""Helper fixtures for LibreNMS API tests."""
from unittest.mock import patch
import pytest
@pytest.fixture(autouse=True)
def mock_librenms_config():
"""Auto-mock LibreNMS configuration for all API tests."""
with (
patch("netbox_librenms_plugin.librenms_api.get_plugin_config") as mock_config,
patch("netbox_librenms_plugin.models.LibreNMSSettings") as mock_settings,
):
# Default config
mock_config.return_value = {
"default": {
"librenms_url": "https://librenms.example.com",
"api_token": "test-token",
"cache_timeout": 300,
"verify_ssl": True,
}
}
mock_settings.objects.filter.return_value.first.return_value = None
yield {"mock_config": mock_config, "mock_settings": mock_settings}

View File

@@ -0,0 +1,367 @@
"""
Tests for multi-server librenms_id helpers.
Covers get_librenms_device_id, set_librenms_device_id, find_by_librenms_id,
and migrate_legacy_librenms_id.
"""
from unittest.mock import MagicMock
class TestGetLibreNMSDeviceId:
"""Tests for get_librenms_device_id()."""
def test_returns_none_when_cf_missing(self):
from netbox_librenms_plugin.utils import get_librenms_device_id
obj = MagicMock()
obj.cf = {}
result = get_librenms_device_id(obj, "default")
assert result is None
def test_returns_int_for_legacy_bare_integer(self):
from netbox_librenms_plugin.utils import get_librenms_device_id
obj = MagicMock()
obj.cf = {"librenms_id": 42}
result = get_librenms_device_id(obj, "default")
assert result == 42
def test_legacy_bare_int_returned_for_any_server_key(self):
"""
Legacy bare integers are returned as a universal fallback for any server_key.
Devices imported before multi-server support store a bare integer in
librenms_id. These must remain discoverable regardless of which server is
active, so the bare-int is returned as-is for any server_key.
"""
from netbox_librenms_plugin.utils import get_librenms_device_id
obj = MagicMock()
obj.cf = {"librenms_id": 99}
assert get_librenms_device_id(obj, "default") == 99
assert get_librenms_device_id(obj, "production") == 99
assert get_librenms_device_id(obj, "secondary") == 99
def test_returns_value_for_matching_server_key(self):
from netbox_librenms_plugin.utils import get_librenms_device_id
obj = MagicMock()
obj.cf = {"librenms_id": {"production": 7, "secondary": 12}}
assert get_librenms_device_id(obj, "production") == 7
def test_returns_none_for_missing_server_key_in_dict(self):
from netbox_librenms_plugin.utils import get_librenms_device_id
obj = MagicMock()
obj.cf = {"librenms_id": {"production": 7}}
result = get_librenms_device_id(obj, "secondary")
assert result is None
def test_returns_none_for_unexpected_type(self):
from netbox_librenms_plugin.utils import get_librenms_device_id
obj = MagicMock()
obj.cf = {"librenms_id": "not-an-int-or-dict"}
result = get_librenms_device_id(obj, "default")
assert result is None
def test_legacy_string_int_returned_for_any_server_key(self):
"""A bare string integer ('42') is coerced and returned for any server_key."""
from netbox_librenms_plugin.utils import get_librenms_device_id
obj = MagicMock()
obj.cf = {"librenms_id": "42"}
assert get_librenms_device_id(obj, "default") == 42
assert get_librenms_device_id(obj, "production") == 42
def test_returns_none_for_bare_boolean(self):
"""bool is a subclass of int; bare True/False must not be treated as a valid ID."""
from netbox_librenms_plugin.utils import get_librenms_device_id
obj = MagicMock()
obj.cf = {"librenms_id": True}
assert get_librenms_device_id(obj, "default") is None
obj.cf = {"librenms_id": False}
assert get_librenms_device_id(obj, "default") is None
def test_returns_none_for_boolean_inside_dict(self):
"""Boolean values inside the JSON dict must be rejected."""
from netbox_librenms_plugin.utils import get_librenms_device_id
obj = MagicMock()
obj.cf = {"librenms_id": {"default": True}}
assert get_librenms_device_id(obj, "default") is None
def test_default_server_key_is_default(self):
from netbox_librenms_plugin.utils import get_librenms_device_id
obj = MagicMock()
obj.cf = {"librenms_id": {"default": 5}}
assert get_librenms_device_id(obj) == 5
class TestFindByLibreNMSId:
"""Tests for find_by_librenms_id()."""
def test_queries_server_key_and_legacy_integer(self):
"""
find_by_librenms_id() issues a Q that covers both the JSON server-key branch
and the legacy bare-int branch in a single filter() call.
We inspect the Q object's children directly because the two branches must
coexist — matching only one would silently miss devices stored in the other
format.
"""
from unittest.mock import MagicMock
from django.db.models import Q
from netbox_librenms_plugin.utils import find_by_librenms_id
mock_model = MagicMock()
mock_qs = MagicMock()
mock_model.objects.filter.return_value = mock_qs
mock_qs.first.return_value = None
find_by_librenms_id(mock_model, 42, "default")
mock_model.objects.filter.assert_called_once()
# Verify the Q predicate covers both the server-key JSON branch and legacy bare-int/string branches
call_args = mock_model.objects.filter.call_args
q_arg = call_args[0][0]
assert isinstance(q_arg, Q)
assert q_arg.connector == "OR"
# The combined Q should contain four children: JSON key (int), JSON key (str), bare-int, bare-string
q_str = str(q_arg)
assert "librenms_id__default" in q_str
assert "custom_field_data__librenms_id__default" in q_str
assert "custom_field_data__librenms_id" in q_str
assert "42" in q_str
def test_returns_first_matching_object(self):
from netbox_librenms_plugin.utils import find_by_librenms_id
expected = MagicMock()
mock_model = MagicMock()
mock_qs = MagicMock()
mock_model.objects.filter.return_value = mock_qs
mock_qs.first.return_value = expected
result = find_by_librenms_id(mock_model, 42, "default")
assert result is expected
def test_returns_none_when_not_found(self):
from unittest.mock import MagicMock
from django.db.models import Q
from netbox_librenms_plugin.utils import find_by_librenms_id
mock_model = MagicMock()
mock_qs = MagicMock()
mock_model.objects.filter.return_value = mock_qs
mock_qs.first.return_value = None
result = find_by_librenms_id(mock_model, 999, "production")
assert result is None
# Any server_key must include legacy bare-int/string fallback conditions
# so that devices imported before multi-server support are still found.
call_args = mock_model.objects.filter.call_args
q_arg = call_args[0][0]
assert isinstance(q_arg, Q)
q_str = str(q_arg)
assert "custom_field_data__librenms_id__production" in q_str
assert "custom_field_data__librenms_id" in q_str
def test_default_server_key_is_default(self):
"""find_by_librenms_id() uses "default" as the server key when no key is passed.
We inspect the Q predicate's children to confirm the key embedded in the
JSON path is exactly "default", not some other fallback value.
"""
from unittest.mock import MagicMock
from django.db.models import Q
from netbox_librenms_plugin.utils import find_by_librenms_id
mock_model = MagicMock()
mock_qs = MagicMock()
mock_model.objects.filter.return_value = mock_qs
mock_qs.first.return_value = None
find_by_librenms_id(mock_model, 42)
mock_model.objects.filter.assert_called_once()
call_args = mock_model.objects.filter.call_args
q_arg = call_args[0][0]
assert isinstance(q_arg, Q)
assert q_arg.connector == "OR"
q_str = str(q_arg)
assert "custom_field_data__librenms_id__default" in q_str
class TestMigrateLegacyLibreNMSId:
"""Tests for migrate_legacy_librenms_id()."""
def test_returns_true_when_migrated(self):
from netbox_librenms_plugin.utils import migrate_legacy_librenms_id
obj = MagicMock()
obj.custom_field_data = {"librenms_id": 42}
result = migrate_legacy_librenms_id(obj, "default")
assert result is True
def test_migrates_integer_to_dict_format(self):
from netbox_librenms_plugin.utils import migrate_legacy_librenms_id
obj = MagicMock()
obj.custom_field_data = {"librenms_id": 42}
migrate_legacy_librenms_id(obj, "production")
assert obj.custom_field_data["librenms_id"] == {"production": 42}
def test_returns_false_when_already_dict(self):
from netbox_librenms_plugin.utils import migrate_legacy_librenms_id
obj = MagicMock()
obj.custom_field_data = {"librenms_id": {"default": 42}}
result = migrate_legacy_librenms_id(obj, "default")
assert result is False
def test_returns_false_when_value_is_none(self):
from netbox_librenms_plugin.utils import migrate_legacy_librenms_id
obj = MagicMock()
obj.custom_field_data = {"librenms_id": None}
result = migrate_legacy_librenms_id(obj, "default")
assert result is False
def test_returns_false_for_boolean_value(self):
"""bool is a subclass of int; True/False must not be migrated."""
from netbox_librenms_plugin.utils import migrate_legacy_librenms_id
obj = MagicMock()
obj.custom_field_data = {"librenms_id": True}
assert migrate_legacy_librenms_id(obj, "default") is False
assert obj.custom_field_data["librenms_id"] is True # unchanged
def test_does_not_call_save(self):
"""migrate_legacy_librenms_id must NOT call obj.save() — caller is responsible."""
from netbox_librenms_plugin.utils import migrate_legacy_librenms_id
obj = MagicMock()
obj.custom_field_data = {"librenms_id": 7}
migrate_legacy_librenms_id(obj, "default")
obj.save.assert_not_called()
def test_preserves_value_in_migrated_dict(self):
from netbox_librenms_plugin.utils import migrate_legacy_librenms_id
obj = MagicMock()
obj.custom_field_data = {"librenms_id": 99}
migrate_legacy_librenms_id(obj, "secondary")
assert obj.custom_field_data["librenms_id"]["secondary"] == 99
class TestLibreNMSIdRoundtrip:
"""get_librenms_device_id should see the value set by set_librenms_device_id."""
def test_set_then_get_returns_same_value(self):
from netbox_librenms_plugin.utils import get_librenms_device_id, set_librenms_device_id
obj = MagicMock()
obj.custom_field_data = {}
obj.cf = obj.custom_field_data # make cf a live view of custom_field_data
set_librenms_device_id(obj, 42, "production")
result = get_librenms_device_id(obj, "production")
assert result == 42
def test_set_multiple_servers_get_correct_each(self):
from netbox_librenms_plugin.utils import get_librenms_device_id, set_librenms_device_id
obj = MagicMock()
obj.custom_field_data = {}
obj.cf = obj.custom_field_data
set_librenms_device_id(obj, 10, "primary")
set_librenms_device_id(obj, 20, "secondary")
assert get_librenms_device_id(obj, "primary") == 10
assert get_librenms_device_id(obj, "secondary") == 20
def test_migrate_then_get_returns_value(self):
from netbox_librenms_plugin.utils import get_librenms_device_id, migrate_legacy_librenms_id
obj = MagicMock()
obj.custom_field_data = {"librenms_id": 55}
obj.cf = obj.custom_field_data
migrate_legacy_librenms_id(obj, "default")
result = get_librenms_device_id(obj, "default")
assert result == 55
class TestSetLibreNMSDeviceId:
"""Tests for set_librenms_device_id in utils.py."""
def test_stores_int_for_valid_device_id(self):
"""Valid integer device_id is stored under server_key."""
from netbox_librenms_plugin.utils import set_librenms_device_id
obj = MagicMock()
obj.custom_field_data = {"librenms_id": None}
set_librenms_device_id(obj, 42, server_key="primary")
assert obj.custom_field_data["librenms_id"] == {"primary": 42}
def test_invalid_device_id_not_stored(self):
"""Non-integer device_id is rejected and nothing is written."""
from netbox_librenms_plugin.utils import set_librenms_device_id
obj = MagicMock()
obj.custom_field_data = {}
set_librenms_device_id(obj, "not-an-int", server_key="primary")
assert "librenms_id" not in obj.custom_field_data
def test_invalid_device_id_does_not_overwrite_existing(self):
"""Existing valid value is preserved when new device_id is invalid."""
from netbox_librenms_plugin.utils import set_librenms_device_id
obj = MagicMock()
obj.custom_field_data = {"librenms_id": {"primary": 10}}
set_librenms_device_id(obj, None, server_key="primary")
assert obj.custom_field_data["librenms_id"] == {"primary": 10}
def test_legacy_bare_int_blocks_write(self):
"""Legacy bare-integer value blocks the write (no silent migration)."""
from netbox_librenms_plugin.utils import set_librenms_device_id
obj = MagicMock()
obj.custom_field_data = {"librenms_id": 7}
set_librenms_device_id(obj, 99, server_key="secondary")
# Write must be skipped; user must use the migration workflow.
assert obj.custom_field_data["librenms_id"] == 7
def test_adds_new_server_key_to_existing_dict(self):
"""Adding a new server key preserves existing keys."""
from netbox_librenms_plugin.utils import set_librenms_device_id
obj = MagicMock()
obj.custom_field_data = {"librenms_id": {"primary": 5}}
set_librenms_device_id(obj, 20, server_key="secondary")
assert obj.custom_field_data["librenms_id"] == {"primary": 5, "secondary": 20}
def test_string_integer_is_coerced(self):
"""String '42' is coerced to int 42."""
from netbox_librenms_plugin.utils import set_librenms_device_id
obj = MagicMock()
obj.custom_field_data = {}
set_librenms_device_id(obj, "42", server_key="primary")
assert obj.custom_field_data["librenms_id"] == {"primary": 42}
def test_unexpected_cf_type_reset_to_empty(self):
"""If custom_field_data has unexpected type for librenms_id, it is reset."""
from netbox_librenms_plugin.utils import set_librenms_device_id
obj = MagicMock()
obj.custom_field_data = {"librenms_id": "unexpected-string"}
set_librenms_device_id(obj, 5, server_key="primary")
assert obj.custom_field_data["librenms_id"] == {"primary": 5}

Some files were not shown because too many files have changed in this diff Show More