first commit
This commit is contained in:
142
netbox_librenms_plugin/__init__.py
Normal file
142
netbox_librenms_plugin/__init__.py
Normal 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
|
||||
0
netbox_librenms_plugin/admin.py
Normal file
0
netbox_librenms_plugin/admin.py
Normal file
0
netbox_librenms_plugin/api/__init__.py
Normal file
0
netbox_librenms_plugin/api/__init__.py
Normal file
13
netbox_librenms_plugin/api/serializers.py
Normal file
13
netbox_librenms_plugin/api/serializers.py
Normal 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"]
|
||||
13
netbox_librenms_plugin/api/urls.py
Normal file
13
netbox_librenms_plugin/api/urls.py
Normal 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
|
||||
106
netbox_librenms_plugin/api/views.py
Normal file
106
netbox_librenms_plugin/api/views.py
Normal 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)
|
||||
6
netbox_librenms_plugin/constants.py
Normal file
6
netbox_librenms_plugin/constants.py
Normal 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
|
||||
13
netbox_librenms_plugin/filters.py
Normal file
13
netbox_librenms_plugin/filters.py
Normal 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"]
|
||||
140
netbox_librenms_plugin/filtersets.py
Normal file
140
netbox_librenms_plugin/filtersets.py
Normal 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)
|
||||
)
|
||||
787
netbox_librenms_plugin/forms.py
Normal file
787
netbox_librenms_plugin/forms.py
Normal 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"
|
||||
)
|
||||
53
netbox_librenms_plugin/import_utils/__init__.py
Normal file
53
netbox_librenms_plugin/import_utils/__init__.py
Normal 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
|
||||
706
netbox_librenms_plugin/import_utils/bulk_import.py
Normal file
706
netbox_librenms_plugin/import_utils/bulk_import.py
Normal 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
|
||||
232
netbox_librenms_plugin/import_utils/cache.py
Normal file
232
netbox_librenms_plugin/import_utils/cache.py
Normal 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)}"
|
||||
)
|
||||
1003
netbox_librenms_plugin/import_utils/device_operations.py
Normal file
1003
netbox_librenms_plugin/import_utils/device_operations.py
Normal file
File diff suppressed because it is too large
Load Diff
288
netbox_librenms_plugin/import_utils/filters.py
Normal file
288
netbox_librenms_plugin/import_utils/filters.py
Normal 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
|
||||
44
netbox_librenms_plugin/import_utils/permissions.py
Normal file
44
netbox_librenms_plugin/import_utils/permissions.py
Normal 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}"
|
||||
)
|
||||
640
netbox_librenms_plugin/import_utils/virtual_chassis.py
Normal file
640
netbox_librenms_plugin/import_utils/virtual_chassis.py
Normal 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
|
||||
257
netbox_librenms_plugin/import_utils/vm_operations.py
Normal file
257
netbox_librenms_plugin/import_utils/vm_operations.py
Normal 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
|
||||
168
netbox_librenms_plugin/import_validation_helpers.py
Normal file
168
netbox_librenms_plugin/import_validation_helpers.py
Normal 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"]
|
||||
)
|
||||
275
netbox_librenms_plugin/jobs.py
Normal file
275
netbox_librenms_plugin/jobs.py
Normal 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}"
|
||||
)
|
||||
1098
netbox_librenms_plugin/librenms_api.py
Normal file
1098
netbox_librenms_plugin/librenms_api.py
Normal file
File diff suppressed because it is too large
Load Diff
23
netbox_librenms_plugin/migrations/0001_initial.py
Normal file
23
netbox_librenms_plugin/migrations/0001_initial.py
Normal 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)),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -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"),
|
||||
),
|
||||
]
|
||||
@@ -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")},
|
||||
),
|
||||
]
|
||||
46
netbox_librenms_plugin/migrations/0004_librenmssettings.py
Normal file
46
netbox_librenms_plugin/migrations/0004_librenmssettings.py
Normal 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",
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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",
|
||||
),
|
||||
]
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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,
|
||||
),
|
||||
]
|
||||
0
netbox_librenms_plugin/migrations/__init__.py
Normal file
0
netbox_librenms_plugin/migrations/__init__.py
Normal file
76
netbox_librenms_plugin/models.py
Normal file
76
netbox_librenms_plugin/models.py
Normal 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}"
|
||||
67
netbox_librenms_plugin/navigation.py
Normal file
67
netbox_librenms_plugin/navigation.py
Normal 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
56
netbox_librenms_plugin/tables/VM_status.py
Normal file
56
netbox_librenms_plugin/tables/VM_status.py
Normal 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",
|
||||
)
|
||||
21
netbox_librenms_plugin/tables/__init__.py
Normal file
21
netbox_librenms_plugin/tables/__init__.py
Normal 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",
|
||||
]
|
||||
161
netbox_librenms_plugin/tables/cables.py
Normal file
161
netbox_librenms_plugin/tables/cables.py
Normal 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",
|
||||
}
|
||||
722
netbox_librenms_plugin/tables/device_status.py
Normal file
722
netbox_librenms_plugin/tables/device_status.py
Normal 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",
|
||||
}
|
||||
616
netbox_librenms_plugin/tables/interfaces.py
Normal file
616
netbox_librenms_plugin/tables/interfaces.py
Normal 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 = " ".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
|
||||
122
netbox_librenms_plugin/tables/ipaddresses.py
Normal file
122
netbox_librenms_plugin/tables/ipaddresses.py
Normal 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)
|
||||
84
netbox_librenms_plugin/tables/locations.py
Normal file
84
netbox_librenms_plugin/tables/locations.py
Normal 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"}
|
||||
38
netbox_librenms_plugin/tables/mappings.py
Normal file
38
netbox_librenms_plugin/tables/mappings.py
Normal 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"}
|
||||
183
netbox_librenms_plugin/tables/vlans.py
Normal file
183
netbox_librenms_plugin/tables/vlans.py
Normal 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)
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 & 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 & 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>
|
||||
@@ -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>
|
||||
@@ -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>…</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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
1
netbox_librenms_plugin/tests/__init__.py
Normal file
1
netbox_librenms_plugin/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Unit test package for netbox_librenms_plugin."""
|
||||
336
netbox_librenms_plugin/tests/conftest.py
Normal file
336
netbox_librenms_plugin/tests/conftest.py
Normal 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",
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
262
netbox_librenms_plugin/tests/mock_librenms_server.py
Normal file
262
netbox_librenms_plugin/tests/mock_librenms_server.py
Normal 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()
|
||||
967
netbox_librenms_plugin/tests/test_background_jobs.py
Normal file
967
netbox_librenms_plugin/tests/test_background_jobs.py
Normal 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
|
||||
311
netbox_librenms_plugin/tests/test_cable_verify.py
Normal file
311
netbox_librenms_plugin/tests/test_cable_verify.py
Normal 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 "<script>" 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 "<img" in remote_device_html
|
||||
3955
netbox_librenms_plugin/tests/test_coverage_actions.py
Normal file
3955
netbox_librenms_plugin/tests/test_coverage_actions.py
Normal file
File diff suppressed because it is too large
Load Diff
1217
netbox_librenms_plugin/tests/test_coverage_api.py
Normal file
1217
netbox_librenms_plugin/tests/test_coverage_api.py
Normal file
File diff suppressed because it is too large
Load Diff
707
netbox_librenms_plugin/tests/test_coverage_api2.py
Normal file
707
netbox_librenms_plugin/tests/test_coverage_api2.py
Normal 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/"
|
||||
2196
netbox_librenms_plugin/tests/test_coverage_base_views.py
Normal file
2196
netbox_librenms_plugin/tests/test_coverage_base_views.py
Normal file
File diff suppressed because it is too large
Load Diff
2037
netbox_librenms_plugin/tests/test_coverage_base_views2.py
Normal file
2037
netbox_librenms_plugin/tests/test_coverage_base_views2.py
Normal file
File diff suppressed because it is too large
Load Diff
320
netbox_librenms_plugin/tests/test_coverage_cache.py
Normal file
320
netbox_librenms_plugin/tests/test_coverage_cache.py
Normal 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
|
||||
2039
netbox_librenms_plugin/tests/test_coverage_device_fields.py
Normal file
2039
netbox_librenms_plugin/tests/test_coverage_device_fields.py
Normal file
File diff suppressed because it is too large
Load Diff
1667
netbox_librenms_plugin/tests/test_coverage_device_operations.py
Normal file
1667
netbox_librenms_plugin/tests/test_coverage_device_operations.py
Normal file
File diff suppressed because it is too large
Load Diff
768
netbox_librenms_plugin/tests/test_coverage_filters.py
Normal file
768
netbox_librenms_plugin/tests/test_coverage_filters.py
Normal 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]
|
||||
61
netbox_librenms_plugin/tests/test_coverage_forms.py
Normal file
61
netbox_librenms_plugin/tests/test_coverage_forms.py
Normal 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
|
||||
1406
netbox_librenms_plugin/tests/test_coverage_list.py
Normal file
1406
netbox_librenms_plugin/tests/test_coverage_list.py
Normal file
File diff suppressed because it is too large
Load Diff
1022
netbox_librenms_plugin/tests/test_coverage_mixins.py
Normal file
1022
netbox_librenms_plugin/tests/test_coverage_mixins.py
Normal file
File diff suppressed because it is too large
Load Diff
1193
netbox_librenms_plugin/tests/test_coverage_sync_interfaces.py
Normal file
1193
netbox_librenms_plugin/tests/test_coverage_sync_interfaces.py
Normal file
File diff suppressed because it is too large
Load Diff
692
netbox_librenms_plugin/tests/test_coverage_sync_view.py
Normal file
692
netbox_librenms_plugin/tests/test_coverage_sync_view.py
Normal 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
|
||||
2577
netbox_librenms_plugin/tests/test_coverage_sync_views.py
Normal file
2577
netbox_librenms_plugin/tests/test_coverage_sync_views.py
Normal file
File diff suppressed because it is too large
Load Diff
2240
netbox_librenms_plugin/tests/test_coverage_sync_views2.py
Normal file
2240
netbox_librenms_plugin/tests/test_coverage_sync_views2.py
Normal file
File diff suppressed because it is too large
Load Diff
980
netbox_librenms_plugin/tests/test_coverage_sync_views3.py
Normal file
980
netbox_librenms_plugin/tests/test_coverage_sync_views3.py
Normal 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)
|
||||
2649
netbox_librenms_plugin/tests/test_coverage_tables.py
Normal file
2649
netbox_librenms_plugin/tests/test_coverage_tables.py
Normal file
File diff suppressed because it is too large
Load Diff
551
netbox_librenms_plugin/tests/test_coverage_utils.py
Normal file
551
netbox_librenms_plugin/tests/test_coverage_utils.py
Normal 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()
|
||||
294
netbox_librenms_plugin/tests/test_coverage_virtual_chassis.py
Normal file
294
netbox_librenms_plugin/tests/test_coverage_virtual_chassis.py
Normal 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
|
||||
367
netbox_librenms_plugin/tests/test_coverage_vlans_table.py
Normal file
367
netbox_librenms_plugin/tests/test_coverage_vlans_table.py
Normal 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_")
|
||||
5894
netbox_librenms_plugin/tests/test_import_utils.py
Normal file
5894
netbox_librenms_plugin/tests/test_import_utils.py
Normal file
File diff suppressed because it is too large
Load Diff
368
netbox_librenms_plugin/tests/test_import_validation_helpers.py
Normal file
368
netbox_librenms_plugin/tests/test_import_validation_helpers.py
Normal 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
|
||||
216
netbox_librenms_plugin/tests/test_init.py
Normal file
216
netbox_librenms_plugin/tests/test_init.py
Normal 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
|
||||
399
netbox_librenms_plugin/tests/test_integration_sync.py
Normal file
399
netbox_librenms_plugin/tests/test_integration_sync.py
Normal 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
|
||||
854
netbox_librenms_plugin/tests/test_integration_virtual_chassis.py
Normal file
854
netbox_librenms_plugin/tests/test_integration_virtual_chassis.py
Normal 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
|
||||
563
netbox_librenms_plugin/tests/test_interface_vlan_sync.py
Normal file
563
netbox_librenms_plugin/tests/test_interface_vlan_sync.py
Normal 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
|
||||
137
netbox_librenms_plugin/tests/test_ip_verify.py
Normal file
137
netbox_librenms_plugin/tests/test_ip_verify.py
Normal 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"
|
||||
1347
netbox_librenms_plugin/tests/test_librenms_api.py
Normal file
1347
netbox_librenms_plugin/tests/test_librenms_api.py
Normal file
File diff suppressed because it is too large
Load Diff
26
netbox_librenms_plugin/tests/test_librenms_api_helpers.py
Normal file
26
netbox_librenms_plugin/tests/test_librenms_api_helpers.py
Normal 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}
|
||||
367
netbox_librenms_plugin/tests/test_librenms_id.py
Normal file
367
netbox_librenms_plugin/tests/test_librenms_id.py
Normal 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
Reference in New Issue
Block a user