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

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

View File

@@ -0,0 +1,237 @@
import logging
from urllib.parse import quote_plus
from dcim.models import Cable, Device, Interface
from django.contrib import messages
from django.contrib.contenttypes.models import ContentType
from django.core.cache import cache
from django.db import transaction
from django.db.models import Q
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from django.views import View
from netbox_librenms_plugin.utils import get_librenms_sync_device
from netbox_librenms_plugin.views.mixins import (
CacheMixin,
LibreNMSAPIMixin,
LibreNMSPermissionMixin,
NetBoxObjectPermissionMixin,
)
logger = logging.getLogger(__name__)
class SyncCablesView(LibreNMSPermissionMixin, NetBoxObjectPermissionMixin, LibreNMSAPIMixin, CacheMixin, View):
"""Create NetBox cables using cached LibreNMS link data."""
required_object_permissions = {
"POST": [
("add", Cable),
("change", Cable),
],
}
def get_selected_interfaces(self, request, initial_device):
"""
Return selected interface entries from POST data.
Each ``select`` value is a ``local_port_id`` (stable LibreNMS identifier)
so that matching against cached link data is user-preference agnostic.
"""
selected_interfaces = []
selected_data = [x for x in request.POST.getlist("select") if x]
if not selected_data:
return None
for port_id in selected_data:
override = request.POST.get(f"device_selection_{port_id}")
device_id = override or initial_device.id
selected_interfaces.append({"device_id": device_id, "local_port_id": port_id})
return selected_interfaces
def get_cached_links_data(self, request, obj):
"""Return cached LibreNMS link data for the given object."""
server_key = getattr(self, "_post_server_key", None) or self.librenms_api.server_key
cache_obj = get_librenms_sync_device(obj, server_key=server_key) or obj
cached_data = cache.get(self.get_cache_key(cache_obj, "links", server_key))
if not cached_data:
return None
return cached_data.get("links", [])
def create_cable(self, local_interface, remote_interface, request):
"""
Create a cable between local and remote interfaces.
Returns:
True on success, False on failure.
"""
try:
Cable.objects.create(
a_terminations=[local_interface],
b_terminations=[remote_interface],
status="connected",
)
return True
except Exception as exc: # pragma: no cover - protects UX
messages.error(request, f"Failed to create cable: {str(exc)}")
return False
def check_existing_cable(self, local_interface, remote_interface):
"""Return True if a cable already exists for either interface."""
from dcim.models import Interface as DCIMInterface
interface_ct = ContentType.objects.get_for_model(DCIMInterface)
return Cable.objects.filter(
Q(
terminations__termination_type=interface_ct,
terminations__termination_id=local_interface.pk,
)
| Q(
terminations__termination_type=interface_ct,
terminations__termination_id=remote_interface.pk,
)
).exists()
def validate_prerequisites(self, cached_links, selected_interfaces):
"""Validate that cached data and selections are present before sync."""
if not cached_links:
messages.error(
self.request,
"Cache has expired. Please refresh the cable data before syncing.",
)
return False
if selected_interfaces is None:
messages.error(self.request, "No interfaces selected for synchronization.")
return False
return True
def process_single_interface(self, interface, cached_links):
"""Process cable creation for a single interface from cached link data."""
port_id = str(interface.get("local_port_id", ""))
try:
link_data = next(link for link in cached_links if str(link.get("local_port_id", "")) == port_id)
# Apply posted device_id (VC member selection) without mutating the cached list.
link_data = {**link_data, "device_id": interface.get("device_id", link_data.get("device_id"))}
return self.handle_cable_creation(link_data, interface)
except StopIteration:
return {"status": "invalid", "interface": port_id}
def verify_cable_creation_requirements(self, link_data):
"""Return True if all required NetBox IDs are present in link data."""
required_fields = [
"netbox_local_interface_id",
"netbox_remote_interface_id",
]
return all(link_data.get(field) for field in required_fields)
def handle_cable_creation(self, link_data, interface):
"""Create a cable from link data and return the operation result."""
display_name = link_data.get("local_port") or interface.get("local_port_id", "")
if not self.verify_cable_creation_requirements(link_data):
if not link_data.get("netbox_remote_device_id") or not link_data.get("netbox_remote_interface_id"):
return {"status": "missing_remote", "interface": display_name}
return {"status": "invalid", "interface": display_name}
try:
local_interface = Interface.objects.get(pk=link_data["netbox_local_interface_id"])
# Honour user's VC member selection: if the selected device_id differs from
# the cached interface's device, look up the same port name on that device.
selected_device_id = interface.get("device_id")
if selected_device_id and str(local_interface.device_id) != str(selected_device_id):
port_name = link_data.get("local_port") or local_interface.name
try:
local_interface = Interface.objects.get(device_id=selected_device_id, name=port_name)
except Interface.DoesNotExist:
logger.debug(
"Port %s not found on device %s; falling back to cached interface",
port_name,
selected_device_id,
)
remote_interface = Interface.objects.get(pk=link_data["netbox_remote_interface_id"])
if self.check_existing_cable(local_interface, remote_interface):
return {"status": "duplicate", "interface": display_name}
if self.create_cable(local_interface, remote_interface, self.request):
return {"status": "valid", "interface": display_name}
return {"status": "invalid", "interface": display_name} # pragma: no cover
except Interface.DoesNotExist:
return {"status": "missing_remote", "interface": display_name}
def process_interface_sync(self, selected_interfaces, cached_links):
"""
Process cable sync for all selected interfaces and return results.
Each interface is processed in its own atomic block so individual
failures roll back only that cable without affecting others.
"""
results = {"valid": [], "invalid": [], "duplicate": [], "missing_remote": []}
for interface in selected_interfaces:
try:
with transaction.atomic():
result = self.process_single_interface(interface, cached_links)
results[result["status"]].append(result.get("interface", ""))
except Exception:
logger.exception("Failed to sync cable for port_id %s", interface.get("local_port_id", ""))
results["invalid"].append(interface.get("local_port_id", ""))
return results
def post(self, request, pk):
"""Sync selected cable connections from LibreNMS into NetBox."""
# Check both plugin write and NetBox object permissions
if error := self.require_all_permissions("POST"):
return error
initial_device = get_object_or_404(Device, pk=pk)
server_key = request.POST.get("server_key") or self.librenms_api.server_key
self._post_server_key = server_key
self._initial_device = initial_device
redirect_url = (
f"{reverse('plugins:netbox_librenms_plugin:device_librenms_sync', args=[initial_device.pk])}?tab=cables"
+ (f"&server_key={quote_plus(server_key)}" if server_key else "")
)
selected_interfaces = self.get_selected_interfaces(request, initial_device)
cached_links = self.get_cached_links_data(request, initial_device)
if not self.validate_prerequisites(cached_links, selected_interfaces):
return redirect(redirect_url)
results = self.process_interface_sync(selected_interfaces, cached_links)
self.display_sync_results(request, results)
return redirect(redirect_url)
def display_sync_results(self, request, results):
"""Display flash messages summarizing the cable sync results."""
if results["missing_remote"]:
messages.error(
request,
f"Remote device or interface not found in NetBox for: {', '.join(results['missing_remote'])}",
)
if results["invalid"]:
messages.error(
request,
f"No LibreNMS link data found for interfaces: {', '.join(results['invalid'])}",
)
if results["duplicate"]:
messages.warning(
request,
f"Cable already exists for interfaces: {', '.join(results['duplicate'])}",
)
if results["valid"]:
messages.success(
request,
f"Successfully created cable for interfaces: {', '.join(results['valid'])}",
)

View File

@@ -0,0 +1,713 @@
import logging
from dcim.models import Device, Manufacturer, Platform
from django.contrib import messages
from django.core.exceptions import ValidationError
from django.db import IntegrityError, transaction
from django.http import HttpResponse
from django.shortcuts import get_object_or_404, redirect
from django.views import View
from virtualization.models import VirtualMachine
from netbox_librenms_plugin.import_utils import _determine_device_name
from netbox_librenms_plugin.import_utils.virtual_chassis import _generate_vc_member_name
from netbox_librenms_plugin.utils import (
find_by_librenms_id,
get_librenms_sync_device,
match_librenms_hardware_to_device_type,
migrate_legacy_librenms_id,
resolve_naming_preferences,
)
from netbox_librenms_plugin.views.mixins import LibreNMSAPIMixin, LibreNMSPermissionMixin, NetBoxObjectPermissionMixin
logger = logging.getLogger(__name__)
class UpdateDeviceNameView(LibreNMSPermissionMixin, NetBoxObjectPermissionMixin, LibreNMSAPIMixin, View):
"""Update NetBox device name from LibreNMS sysName."""
required_object_permissions = {
"POST": [("change", Device)],
}
def post(self, request, pk):
"""Sync the device name from LibreNMS sysName."""
if error := self.require_all_permissions("POST"):
return error
device = get_object_or_404(Device, pk=pk)
# For VC members without their own librenms_id, use the VC sync device
librenms_lookup_device = device
if hasattr(device, "virtual_chassis") and device.virtual_chassis:
if not device.cf.get("librenms_id"):
sync_device = get_librenms_sync_device(device)
if sync_device:
librenms_lookup_device = sync_device
self.librenms_id = self.librenms_api.get_librenms_id(librenms_lookup_device)
if not self.librenms_id:
messages.error(request, "Device not found in LibreNMS")
return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk)
success, device_info = self.librenms_api.get_device_info(self.librenms_id)
if not success or not device_info:
messages.error(request, "Failed to retrieve device info from LibreNMS")
return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk)
# Bail out early when LibreNMS has no usable name the fallback
# names that _determine_device_name generates (e.g. "device-42")
# are only useful during import, not for renaming an existing device.
if not (device_info.get("sysName") or device_info.get("hostname")):
messages.warning(request, "No name could be determined from LibreNMS")
return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk)
use_sysname, strip_domain = resolve_naming_preferences(request)
resolved_name = _determine_device_name(
device_info,
use_sysname=use_sysname,
strip_domain=strip_domain,
)
# For VC members, generate the expected VC member name
if (
resolved_name
and hasattr(device, "virtual_chassis")
and device.virtual_chassis is not None
and device.vc_position is not None
):
resolved_name = _generate_vc_member_name(
resolved_name,
device.vc_position,
serial=getattr(device, "serial", None),
)
if not resolved_name:
messages.warning(request, "No name could be determined from LibreNMS")
return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk)
old_name = device.name
device.name = resolved_name
try:
device.full_clean()
device.save()
except (ValidationError, IntegrityError) as e:
device.name = old_name
error_msg = e.message_dict if hasattr(e, "message_dict") else str(e)
messages.error(request, f"Failed to update device name to '{resolved_name}': {error_msg}")
return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk)
messages.success(request, f"Device name updated from '{old_name}' to '{resolved_name}'")
return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk)
class UpdateDeviceSerialView(LibreNMSPermissionMixin, NetBoxObjectPermissionMixin, LibreNMSAPIMixin, View):
"""Update NetBox device serial number from LibreNMS."""
required_object_permissions = {
"POST": [("change", Device)],
}
def post(self, request, pk):
"""Sync the device serial number from LibreNMS."""
# Check both plugin write and NetBox object permissions
if error := self.require_all_permissions("POST"):
return error
device = get_object_or_404(Device, pk=pk)
self.librenms_id = self.librenms_api.get_librenms_id(device)
if not self.librenms_id:
messages.error(request, "Device not found in LibreNMS")
return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk)
success, device_info = self.librenms_api.get_device_info(self.librenms_id)
if not success or not device_info:
messages.error(request, "Failed to retrieve device info from LibreNMS")
return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk)
serial = device_info.get("serial")
if not serial or serial == "-":
messages.warning(request, "No serial number available in LibreNMS")
return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk)
old_serial = device.serial
device.serial = serial
try:
device.full_clean()
device.save()
except (ValidationError, IntegrityError) as e:
device.serial = old_serial
error_msg = e.message_dict if hasattr(e, "message_dict") else str(e)
messages.error(request, f"Failed to update serial to '{serial}': {error_msg}")
return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk)
if old_serial:
messages.success(
request,
f"Device serial updated from '{old_serial}' to '{serial}'",
)
else:
messages.success(request, f"Device serial set to '{serial}'")
return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk)
class UpdateDeviceTypeView(LibreNMSPermissionMixin, NetBoxObjectPermissionMixin, LibreNMSAPIMixin, View):
"""Update NetBox DeviceType using LibreNMS hardware metadata."""
required_object_permissions = {
"POST": [("change", Device)],
}
def post(self, request, pk):
"""Sync the device type from LibreNMS hardware info."""
# Check both plugin write and NetBox object permissions
if error := self.require_all_permissions("POST"):
return error
device = get_object_or_404(Device, pk=pk)
self.librenms_id = self.librenms_api.get_librenms_id(device)
if not self.librenms_id:
messages.error(request, "Device not found in LibreNMS")
return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk)
success, device_info = self.librenms_api.get_device_info(self.librenms_id)
if not success or not device_info:
messages.error(request, "Failed to retrieve device info from LibreNMS")
return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk)
hardware = device_info.get("hardware")
if not hardware:
messages.warning(request, "No hardware information available in LibreNMS")
return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk)
match_result = match_librenms_hardware_to_device_type(hardware)
if not match_result or not match_result["matched"]:
messages.error(
request,
f"No matching DeviceType found for hardware '{hardware}'",
)
return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk)
device_type = match_result["device_type"]
old_device_type = device.device_type
device.device_type = device_type
try:
device.full_clean()
device.save()
except (ValidationError, IntegrityError) as e:
device.device_type = old_device_type
error_msg = e.message_dict if hasattr(e, "message_dict") else str(e)
messages.error(request, f"Failed to update device type to '{device_type}': {error_msg}")
return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk)
messages.success(
request,
f"Device type updated from '{old_device_type}' to '{device_type}'",
)
return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk)
class UpdateDevicePlatformView(LibreNMSPermissionMixin, NetBoxObjectPermissionMixin, LibreNMSAPIMixin, View):
"""Update NetBox Platform based on LibreNMS OS info."""
required_object_permissions = {
"POST": [("change", Device)],
}
def post(self, request, pk):
"""Sync the device platform from LibreNMS OS name."""
# Check both plugin write and NetBox object permissions
if error := self.require_all_permissions("POST"):
return error
device = get_object_or_404(Device, pk=pk)
self.librenms_id = self.librenms_api.get_librenms_id(device)
if not self.librenms_id:
messages.error(request, "Device not found in LibreNMS")
return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk)
success, device_info = self.librenms_api.get_device_info(self.librenms_id)
if not success or not device_info:
messages.error(request, "Failed to retrieve device info from LibreNMS")
return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk)
os_name = device_info.get("os")
if not os_name:
messages.warning(request, "No OS information available in LibreNMS")
return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk)
platform_name = os_name
try:
platform = Platform.objects.get(name__iexact=platform_name)
except Platform.DoesNotExist:
messages.error(
request,
"Platform '{}' does not exist in NetBox. Use 'Create & Sync' button to create it first.".format(
platform_name
),
)
return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk)
old_platform = device.platform
device.platform = platform
try:
device.full_clean()
device.save()
except (ValidationError, IntegrityError) as e:
device.platform = old_platform
error_msg = e.message_dict if hasattr(e, "message_dict") else str(e)
messages.error(request, f"Failed to update platform to '{platform}': {error_msg}")
return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk)
if old_platform:
messages.success(
request,
f"Device platform updated from '{old_platform}' to '{platform}'",
)
else:
messages.success(request, f"Device platform set to '{platform}'")
return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk)
class CreateAndAssignPlatformView(LibreNMSPermissionMixin, NetBoxObjectPermissionMixin, LibreNMSAPIMixin, View):
"""Create a new Platform and assign it to the device."""
required_object_permissions = {
"POST": [
("change", Device),
("add", Platform),
],
}
def post(self, request, pk):
"""Create a new platform and assign it to the device."""
# Check both plugin write and NetBox object permissions
if error := self.require_all_permissions("POST"):
return error
device = get_object_or_404(Device, pk=pk)
platform_name = request.POST.get("platform_name")
manufacturer_id = request.POST.get("manufacturer")
if not platform_name:
messages.error(request, "Platform name is required")
return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk)
if Platform.objects.filter(name__iexact=platform_name).exists():
messages.warning(
request,
f"Platform '{platform_name}' already exists. Use the regular sync button.",
)
return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk)
manufacturer = None
if manufacturer_id:
try:
manufacturer = Manufacturer.objects.get(pk=manufacturer_id)
except Manufacturer.DoesNotExist:
pass
with transaction.atomic():
try:
platform = Platform(
name=platform_name,
manufacturer=manufacturer,
)
platform.full_clean()
platform.save()
except ValidationError as e:
transaction.set_rollback(True)
error_msg = e.message_dict if hasattr(e, "message_dict") else str(e)
logger.exception(
"ValidationError creating platform '%s' for device pk=%s: %s",
platform_name,
pk,
error_msg,
)
messages.error(
request,
f"Platform '{platform_name}' could not be created: {error_msg}",
)
return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk)
except IntegrityError as e:
transaction.set_rollback(True)
logger.exception(
"IntegrityError creating platform '%s' for device pk=%s",
platform_name,
pk,
)
messages.error(
request,
f"Platform '{platform_name}' could not be created: {e}",
)
return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk)
try:
device = Device.objects.select_for_update().get(pk=pk)
except Device.DoesNotExist:
transaction.set_rollback(True)
messages.error(request, "Device no longer exists.")
return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk)
device.platform = platform
try:
device.full_clean()
except ValidationError as e:
transaction.set_rollback(True)
error_msg = e.message_dict if hasattr(e, "message_dict") else str(e)
logger.exception(
"ValidationError validating device pk=%s: %s",
pk,
error_msg,
)
messages.error(
request,
f"Device (pk={pk}) validation failed: {error_msg}",
)
return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk)
try:
device.save()
except IntegrityError as e:
transaction.set_rollback(True)
logger.exception("IntegrityError saving device pk=%s after platform assignment", pk)
messages.error(
request,
f"Error saving device (pk={pk}): {e}",
)
return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk)
messages.success(
request,
f"Created platform '{platform}' and assigned to device",
)
return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk)
class AssignVCSerialView(LibreNMSPermissionMixin, NetBoxObjectPermissionMixin, LibreNMSAPIMixin, View):
"""Assign serial numbers to each virtual chassis member."""
required_object_permissions = {
"POST": [("change", Device)],
}
def post(self, request, pk):
"""Sync serial numbers to virtual chassis member devices."""
# Check both plugin write and NetBox object permissions
if error := self.require_all_permissions("POST"):
return error
device = get_object_or_404(Device, pk=pk)
if not device.virtual_chassis:
messages.error(request, "Device is not part of a virtual chassis")
return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk)
assignments_made = 0
errors = []
counter = 1
while f"serial_{counter}" in request.POST:
serial = request.POST.get(f"serial_{counter}")
member_id = request.POST.get(f"member_id_{counter}")
if not member_id:
counter += 1
continue
try:
member = Device.objects.get(pk=member_id)
if not member.virtual_chassis or member.virtual_chassis.pk != device.virtual_chassis.pk:
errors.append(f"{member.name} is not part of the same virtual chassis")
counter += 1
continue
old_serial = member.serial
member.serial = serial
try:
member.full_clean()
member.save()
except (ValidationError, IntegrityError) as e:
member.serial = old_serial
error_msg = e.message_dict if hasattr(e, "message_dict") else str(e)
errors.append(f"Failed to set serial on {member.name}: {error_msg}")
counter += 1
continue
assignments_made += 1
except Device.DoesNotExist:
errors.append(f"Device with ID {member_id} not found")
except Exception as exc: # pragma: no cover - defensive guard
errors.append(f"Error assigning serial to member {member_id}: {str(exc)}")
counter += 1
if assignments_made > 0:
messages.success(
request,
f"Successfully assigned {assignments_made} serial number(s) to VC members",
)
if errors:
for error in errors:
messages.error(request, error)
if assignments_made == 0 and not errors:
messages.info(request, "No serial assignments were made")
return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk)
class RemoveServerMappingView(LibreNMSPermissionMixin, NetBoxObjectPermissionMixin, View):
"""Remove a single server entry from the device's (or VM's) librenms_id custom field dict."""
required_object_permissions = {
"POST": [("change", Device), ("change", VirtualMachine)],
}
def _get_object(self, object_type, pk):
"""Return the Device or VirtualMachine for the given pk."""
model = VirtualMachine if object_type == "vm" else Device
return get_object_or_404(model, pk=pk), model
def _sync_url_name(self, object_type):
if object_type == "vm":
return "plugins:netbox_librenms_plugin:vm_librenms_sync"
return "plugins:netbox_librenms_plugin:device_librenms_sync"
def _normalize_librenms_mapping(self, value):
if isinstance(value, bool):
return {}
if isinstance(value, int):
return {"default": value}
if isinstance(value, str) and value.isdigit():
return {"default": int(value)}
return value if isinstance(value, dict) else {}
def post(self, request, pk):
# Scope required permissions to the specific model being modified before checking.
object_type = request.POST.get("object_type", "device")
if object_type == "virtualmachine":
object_type = "vm"
if object_type not in ("device", "vm"):
return HttpResponse(f"Invalid object_type: {object_type!r}", status=400)
target_model = VirtualMachine if object_type == "vm" else Device
self.required_object_permissions = {"POST": [("change", target_model)]}
if error := self.require_all_permissions("POST"):
return error
obj, model = self._get_object(object_type, pk)
sync_url = self._sync_url_name(object_type)
server_key = request.POST.get("server_key", "").strip()
if not server_key:
messages.error(request, "No server_key provided.")
return redirect(sync_url, pk=pk)
cf_value = self._normalize_librenms_mapping(obj.custom_field_data.get("librenms_id"))
if not isinstance(cf_value, dict) or server_key not in cf_value:
messages.warning(request, f"No mapping found for server '{server_key}'.")
return redirect(sync_url, pk=pk)
# Refuse to remove mappings for servers that are still configured in the plugin.
# Only orphaned (unconfigured) mappings may be removed via this endpoint.
# Guard both multi-server mode (servers dict) and legacy single-server mode
# (top-level librenms_url in plugin config, which implicitly defines "default")
# but only when no servers section is configured (pure legacy mode).
from django.conf import settings as django_settings
plugins_cfg = django_settings.PLUGINS_CONFIG.get("netbox_librenms_plugin", {})
configured_servers = plugins_cfg.get("servers") or {}
if not isinstance(configured_servers, dict):
configured_servers = {}
legacy_url_configured = bool(plugins_cfg.get("librenms_url"))
if server_key in configured_servers or (
legacy_url_configured and not configured_servers and server_key == "default"
):
messages.error(
request,
f"Cannot remove mapping for configured server '{server_key}'. "
"Remove the server from plugin configuration first, then retry.",
)
return redirect(sync_url, pk=pk)
with transaction.atomic():
try:
obj_locked = model.objects.select_for_update().get(pk=pk)
except model.DoesNotExist:
messages.error(request, f"{model.__name__} no longer exists.")
return redirect(sync_url, pk=pk)
cf = self._normalize_librenms_mapping(obj_locked.custom_field_data.get("librenms_id"))
# Re-check after acquiring lock; mirror the pre-transaction protection logic
_is_protected = server_key in configured_servers or (
legacy_url_configured and not configured_servers and server_key == "default"
)
if isinstance(cf, dict) and server_key in cf and not _is_protected:
del cf[server_key]
obj_locked.custom_field_data["librenms_id"] = cf if cf else None
try:
obj_locked.full_clean()
obj_locked.save()
except ValidationError as exc:
transaction.set_rollback(True)
error_msg = exc.message_dict if hasattr(exc, "message_dict") else str(exc)
logger.exception(
"Validation error removing LibreNMS mapping for server %r: %s", server_key, error_msg
)
messages.error(request, f"Validation error removing LibreNMS mapping: {error_msg}")
return redirect(sync_url, pk=pk)
except Exception as exc:
transaction.set_rollback(True)
logger.exception("Unexpected error removing LibreNMS mapping for server %r", server_key)
messages.error(request, f"Unexpected error removing LibreNMS mapping: {exc}")
return redirect(sync_url, pk=pk)
messages.success(request, f"Removed LibreNMS mapping for server '{server_key}'.")
else:
messages.warning(request, f"Mapping for server '{server_key}' was already removed.")
return redirect(sync_url, pk=pk)
class ConvertLegacyLibreNMSIdView(LibreNMSPermissionMixin, NetBoxObjectPermissionMixin, LibreNMSAPIMixin, View):
"""
Convert a legacy bare-integer librenms_id to the server-scoped JSON dict format.
Only allowed when the NetBox serial matches the LibreNMS serial, so the
association can be verified before scoping the ID to the active server.
"""
required_object_permissions = {
"POST": [("change", Device), ("change", VirtualMachine)],
}
def _get_model_and_object(self, object_type, pk):
model = VirtualMachine if object_type == "vm" else Device
return model, get_object_or_404(model, pk=pk)
def _sync_url(self, object_type, pk):
name = "vm_librenms_sync" if object_type == "vm" else "device_librenms_sync"
return redirect(f"plugins:netbox_librenms_plugin:{name}", pk=pk)
def post(self, request, pk):
object_type = request.POST.get("object_type", "device")
if object_type == "virtualmachine":
object_type = "vm"
if object_type not in ("device", "vm"):
return HttpResponse(f"Invalid object_type: {object_type!r}", status=400)
target_model = VirtualMachine if object_type == "vm" else Device
self.required_object_permissions = {"POST": [("change", target_model)]}
if error := self.require_all_permissions("POST"):
return error
model, obj = self._get_model_and_object(object_type, pk)
server_key = self.librenms_api.server_key
# Verify the device actually has a legacy bare-int librenms_id
cf_value = obj.custom_field_data.get("librenms_id")
if isinstance(cf_value, bool):
messages.error(request, "librenms_id has an invalid boolean value; cannot convert.")
return self._sync_url(object_type, pk)
if not isinstance(cf_value, (int, str)):
messages.warning(request, "librenms_id is already in the server-scoped JSON format.")
return self._sync_url(object_type, pk)
if isinstance(cf_value, str):
if not cf_value.isdigit():
messages.error(request, "librenms_id is not a valid integer; cannot convert.")
return self._sync_url(object_type, pk)
# Verify serial match before converting
librenms_id = int(cf_value) if isinstance(cf_value, str) else cf_value
success, device_info = self.librenms_api.get_device_info(librenms_id)
if not success or not device_info:
messages.error(request, "Could not retrieve device info from LibreNMS to verify serial.")
return self._sync_url(object_type, pk)
librenms_serial = (device_info.get("serial") or "").strip()
netbox_serial = (getattr(obj, "serial", None) or "").strip()
# VMs have no serial field in NetBox; skip the serial gate for them.
is_vm = object_type == "vm"
if not is_vm and (not netbox_serial or not librenms_serial or netbox_serial != librenms_serial):
messages.error(
request,
"Serial number mismatch — cannot convert legacy ID without serial confirmation.",
)
return self._sync_url(object_type, pk)
with transaction.atomic():
try:
locked = model.objects.select_for_update().get(pk=pk)
except model.DoesNotExist:
messages.error(request, f"{model.__name__} no longer exists.")
return self._sync_url(object_type, pk)
# Re-check preconditions on the locked row (another admin may have
# changed cf_value or serial between the initial read and the lock).
locked_cf = locked.custom_field_data.get("librenms_id")
if not isinstance(locked_cf, (int, str)) or isinstance(locked_cf, bool):
messages.warning(request, "librenms_id is already in the server-scoped JSON format.")
return self._sync_url(object_type, pk)
locked_id = int(locked_cf) if isinstance(locked_cf, str) and locked_cf.isdigit() else locked_cf
if not isinstance(locked_id, int):
messages.error(request, "librenms_id changed before lock was acquired; aborting.")
return self._sync_url(object_type, pk)
locked_serial = (getattr(locked, "serial", None) or "").strip()
if locked_id != librenms_id or locked_serial != netbox_serial:
messages.error(request, "Device data changed before lock was acquired; aborting conversion.")
return self._sync_url(object_type, pk)
# Check that no other object already owns this ID (server-scoped or legacy)
match = find_by_librenms_id(model, librenms_id, server_key)
conflict = match is not None and match.pk != locked.pk
if conflict:
transaction.set_rollback(True)
messages.error(
request,
f"Another {model.__name__} already has librenms_id {librenms_id} "
f"for server '{server_key}'; cannot convert.",
)
return self._sync_url(object_type, pk)
migrated = migrate_legacy_librenms_id(locked, server_key)
if not migrated:
messages.warning(request, "librenms_id is already in the server-scoped JSON format.")
return self._sync_url(object_type, pk)
try:
locked.full_clean()
locked.save()
except ValidationError as exc:
transaction.set_rollback(True)
error_msg = exc.message_dict if hasattr(exc, "message_dict") else str(exc)
messages.error(request, f"Failed to save converted librenms_id: {error_msg}")
return self._sync_url(object_type, pk)
except Exception as exc:
transaction.set_rollback(True)
logger.exception("Failed saving converted librenms_id for %s/%s", object_type, pk)
messages.error(request, f"Failed to save converted librenms_id: {exc}")
return self._sync_url(object_type, pk)
messages.success(
request,
f"Converted legacy librenms_id {librenms_id}{{'{server_key}': {librenms_id}}}.",
)
return self._sync_url(object_type, pk)

View File

@@ -0,0 +1,141 @@
from dcim.models import Device
from django.contrib import messages
from django.shortcuts import get_object_or_404, redirect
from django.views import View
from virtualization.models import VirtualMachine
from netbox_librenms_plugin.forms import AddToLIbreSNMPV1V2, AddToLIbreSNMPV3
from netbox_librenms_plugin.views.mixins import LibreNMSAPIMixin, LibreNMSPermissionMixin
class AddDeviceToLibreNMSView(LibreNMSPermissionMixin, LibreNMSAPIMixin, View):
"""Add a NetBox device or VM to LibreNMS via the API."""
def get_form_class(self):
"""Return the appropriate SNMP form class based on the SNMP version."""
snmp_version = self.request.POST.get("snmp_version")
if not snmp_version:
snmp_version = self.request.POST.get("v1v2-snmp_version") or self.request.POST.get("v3-snmp_version")
if snmp_version in ("v1", "v2c"):
return AddToLIbreSNMPV1V2
return AddToLIbreSNMPV3
def get_object(self, object_id, object_type=None):
"""
Return the Device or VirtualMachine for the given ID.
Uses object_type hint when provided to avoid PK collision ambiguity
(Device and VirtualMachine share independent PK sequences).
"""
if object_type == "virtualmachine":
return get_object_or_404(VirtualMachine, pk=object_id)
try:
return Device.objects.get(pk=object_id)
except Device.DoesNotExist:
return get_object_or_404(VirtualMachine, pk=object_id)
def post(self, request, object_id):
"""Add a device to LibreNMS using the submitted SNMP form."""
# Check write permission before adding device to LibreNMS
if error := self.require_write_permission():
return error
self.object = self.get_object(object_id, request.POST.get("object_type"))
form_class = self.get_form_class()
snmp_version = request.POST.get("v1v2-snmp_version") or request.POST.get("v3-snmp_version")
prefix = "v1v2" if snmp_version in ("v1", "v2c") else "v3"
form = form_class(request.POST, prefix=prefix)
if form.is_valid():
# Inject snmp_version from toggle into cleaned_data for v1/v2c forms
if snmp_version in ("v1", "v2c"):
form.cleaned_data["snmp_version"] = snmp_version
return self.form_valid(form, snmp_version=snmp_version)
for field, errors in form.errors.items():
for error in errors:
messages.error(request, f"{field}: {error}")
return redirect(self.object.get_absolute_url())
def form_valid(self, form, snmp_version=None):
"""Submit the validated SNMP form data to the LibreNMS API."""
data = form.cleaned_data
# Use the snmp_version from toggle/form for v1/v2c, or from form data for v3
version = snmp_version or data.get("snmp_version")
device_data = {
"hostname": data.get("hostname"),
"snmp_version": version,
"force_add": data.get("force_add", False),
}
if data.get("port"):
device_data["port"] = data.get("port")
if data.get("transport"):
device_data["transport"] = data.get("transport")
if data.get("port_association_mode"):
device_data["port_association_mode"] = data.get("port_association_mode")
if data.get("poller_group"):
try:
device_data["poller_group"] = int(data.get("poller_group"))
except (ValueError, TypeError):
pass
if device_data["snmp_version"] in ("v1", "v2c"):
device_data["community"] = data.get("community")
elif device_data["snmp_version"] == "v3":
device_data.update(
{
"authlevel": data.get("authlevel"),
"authname": data.get("authname"),
"authpass": data.get("authpass"),
"authalgo": data.get("authalgo"),
"cryptopass": data.get("cryptopass"),
"cryptoalgo": data.get("cryptoalgo"),
}
)
else:
messages.error(self.request, "Unknown SNMP version.")
return redirect(self.object.get_absolute_url())
success, message = self.librenms_api.add_device(device_data)
if success:
messages.success(self.request, message)
else:
messages.error(self.request, message)
return redirect(self.object.get_absolute_url())
class UpdateDeviceLocationView(LibreNMSPermissionMixin, LibreNMSAPIMixin, View):
"""Update the LibreNMS site/location based on the NetBox site."""
def post(self, request, pk):
"""Sync the device location to LibreNMS from the NetBox site."""
# Check write permission before updating location in LibreNMS
if error := self.require_write_permission():
return error
device = get_object_or_404(Device, pk=pk)
self.librenms_id = self.librenms_api.get_librenms_id(device)
if device.site:
librenms_api = self.librenms_api
field_data = {
"field": ["location", "override_sysLocation"],
"data": [device.site.name, "1"],
}
success, message = librenms_api.update_device_field(self.librenms_id, field_data)
if success:
messages.success(
request,
f"Device location updated in LibreNMS to {device.site.name}",
)
else:
messages.error(request, f"Failed to update device location in LibreNMS: {message}")
else:
messages.warning(request, "Device has no associated site in NetBox")
return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk)

View File

@@ -0,0 +1,398 @@
from urllib.parse import quote_plus
from dcim.models import Device, Interface, MACAddress
from django.contrib import messages
from django.core.cache import cache
from django.db import transaction
from django.http import Http404, JsonResponse
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from django.views import View
from virtualization.models import VirtualMachine, VMInterface
from netbox_librenms_plugin.models import InterfaceTypeMapping
from netbox_librenms_plugin.utils import (
convert_speed_to_kbps,
get_interface_name_field,
get_librenms_device_id,
get_librenms_sync_device,
set_librenms_device_id,
)
from netbox_librenms_plugin.views.mixins import (
CacheMixin,
LibreNMSAPIMixin,
LibreNMSPermissionMixin,
NetBoxObjectPermissionMixin,
VlanAssignmentMixin,
)
class SyncInterfacesView(
LibreNMSPermissionMixin, NetBoxObjectPermissionMixin, LibreNMSAPIMixin, VlanAssignmentMixin, CacheMixin, View
):
"""Sync selected interfaces from LibreNMS into NetBox."""
def get_required_permissions_for_object_type(self, object_type):
"""Return the required permissions based on object type."""
if object_type == "device":
return [("add", Interface), ("change", Interface)]
elif object_type == "virtualmachine":
return [("add", VMInterface), ("change", VMInterface)]
else:
raise Http404(f"Invalid object type: {object_type}")
def post(self, request, object_type, object_id):
"""Sync selected interfaces from LibreNMS into NetBox."""
# Set permissions dynamically based on object type
self.required_object_permissions = {
"POST": self.get_required_permissions_for_object_type(object_type),
}
# Check both plugin write and NetBox object permissions
if error := self.require_all_permissions("POST"):
return error
url_name = (
"dcim:device_librenms_sync"
if object_type == "device"
else "plugins:netbox_librenms_plugin:vm_librenms_sync"
)
obj = self.get_object(object_type, object_id)
self.object = obj # Store for use in sync methods
# Read server_key from POST so we use the exact server the user was viewing
server_key = request.POST.get("server_key") or self.librenms_api.server_key
self._post_server_key = server_key
interface_name_field = get_interface_name_field(request)
self.interface_name_field = interface_name_field
selected_interfaces = self.get_selected_interfaces(request, interface_name_field)
exclude_columns = request.POST.getlist("exclude_columns")
redirect_url = (
reverse(url_name, kwargs={"pk": object_id})
+ f"?tab=interfaces&interface_name_field={interface_name_field}"
+ (f"&server_key={quote_plus(server_key)}" if server_key else "")
)
if selected_interfaces is None:
return redirect(redirect_url)
ports_data = self.get_cached_ports_data(request, obj, server_key)
if ports_data is None:
return redirect(redirect_url)
# Prepare VLAN lookup maps if VLAN sync is enabled
vlan_groups = self.get_vlan_groups_for_device(obj)
lookup_maps = self._build_vlan_lookup_maps(vlan_groups)
self._lookup_maps = lookup_maps
self.sync_selected_interfaces(obj, selected_interfaces, ports_data, exclude_columns, interface_name_field)
messages.success(request, "Selected interfaces synced successfully.")
return redirect(redirect_url)
def get_object(self, object_type, object_id):
"""Return the Device or VirtualMachine for the given type and ID."""
if object_type == "device":
return get_object_or_404(Device, pk=object_id)
if object_type == "virtualmachine":
return get_object_or_404(VirtualMachine, pk=object_id)
raise Http404("Invalid object type.")
def get_selected_interfaces(self, request, interface_name_field):
"""Return the list of selected interface names from POST data."""
selected_interfaces = request.POST.getlist("select")
if not selected_interfaces:
messages.error(request, "No interfaces selected for synchronization.")
return None
return selected_interfaces
def get_cached_ports_data(self, request, obj, server_key=None):
"""Return cached LibreNMS port data for the given object."""
if server_key is None:
server_key = self.librenms_api.server_key
# On VC member pages the GET tab writes ports under the resolved sync device's
# cache key. Resolve the same device here so the POST path reads the same entry.
cache_obj = obj
if isinstance(obj, Device) and not get_librenms_device_id(obj, server_key, auto_save=False):
sync_device = get_librenms_sync_device(obj, server_key=server_key)
if sync_device is not None:
cache_obj = sync_device
cached_data = cache.get(self.get_cache_key(cache_obj, "ports", server_key))
if not cached_data:
messages.warning(
request,
"No cached data found. Please refresh the data before syncing.",
)
return None
return cached_data.get("ports", [])
def sync_selected_interfaces(
self,
obj,
selected_interfaces,
ports_data,
exclude_columns,
interface_name_field,
):
"""Create or update NetBox interfaces from LibreNMS port data."""
with transaction.atomic():
for port in ports_data:
port_name = port.get(interface_name_field)
if port_name in selected_interfaces:
self.sync_interface(obj, port, exclude_columns, interface_name_field)
def sync_interface(self, obj, librenms_interface, exclude_columns, interface_name_field):
"""Create or update a single NetBox interface from LibreNMS data."""
interface_name = librenms_interface.get(interface_name_field)
if isinstance(obj, Device):
device_selection_key = f"device_selection_{interface_name}"
selected_device_id = self.request.POST.get(device_selection_key)
if selected_device_id:
try:
target_device = Device.objects.get(id=selected_device_id)
# Validate the target is the current device or a VC member
if hasattr(obj, "virtual_chassis") and obj.virtual_chassis:
valid_ids = set(obj.virtual_chassis.members.values_list("id", flat=True))
if target_device.id not in valid_ids:
target_device = obj
elif target_device.id != obj.id:
target_device = obj
except (Device.DoesNotExist, ValueError, TypeError):
target_device = obj
else:
target_device = obj
interface, _ = Interface.objects.get_or_create(device=target_device, name=interface_name)
elif isinstance(obj, VirtualMachine):
interface, _ = VMInterface.objects.get_or_create(virtual_machine=obj, name=interface_name)
else:
raise ValueError("Invalid object type.")
netbox_type = None
if isinstance(obj, Device):
netbox_type = self.get_netbox_interface_type(librenms_interface)
self.update_interface_attributes(
interface,
librenms_interface,
netbox_type,
exclude_columns,
interface_name_field,
)
# Sync VLANs if not excluded
if "vlans" not in exclude_columns:
self._sync_interface_vlans(interface, librenms_interface, interface_name)
def get_netbox_interface_type(self, librenms_interface):
"""Return the NetBox interface type mapped from LibreNMS type and speed."""
speed = convert_speed_to_kbps(librenms_interface.get("ifSpeed"))
mappings = InterfaceTypeMapping.objects.filter(librenms_type=librenms_interface.get("ifType"))
if speed is not None:
speed_mapping = mappings.filter(librenms_speed__lte=speed).order_by("-librenms_speed").first()
mapping = speed_mapping or mappings.filter(librenms_speed__isnull=True).first()
else:
mapping = mappings.filter(librenms_speed__isnull=True).first()
return mapping.netbox_type if mapping else "other"
def handle_mac_address(self, interface, ifPhysAddress):
"""Assign or create the MAC address for the given interface."""
if ifPhysAddress:
existing_mac = interface.mac_addresses.filter(mac_address=ifPhysAddress).first()
if existing_mac:
mac_obj = existing_mac
else:
mac_obj = MACAddress.objects.create(mac_address=ifPhysAddress)
interface.mac_addresses.add(mac_obj)
if hasattr(interface, "primary_mac_address"):
interface.primary_mac_address = mac_obj
def update_interface_attributes(
self,
interface,
librenms_interface,
netbox_type,
exclude_columns,
interface_name_field,
):
"""Update interface fields from LibreNMS data, respecting excluded columns."""
is_device_interface = isinstance(interface, Interface)
LIBRENMS_TO_NETBOX_MAPPING = {
interface_name_field: "name",
"ifType": "type",
"ifSpeed": "speed",
"ifAlias": "description",
"ifMtu": "mtu",
}
for librenms_key, netbox_key in LIBRENMS_TO_NETBOX_MAPPING.items():
if netbox_key in exclude_columns:
continue
if librenms_key == "ifSpeed":
speed = convert_speed_to_kbps(librenms_interface.get(librenms_key))
setattr(interface, netbox_key, speed)
elif librenms_key == "ifType":
if is_device_interface and hasattr(interface, netbox_key):
setattr(interface, netbox_key, netbox_type)
elif librenms_key == "ifAlias":
interface_name = librenms_interface.get(interface_name_field)
if librenms_interface.get("ifAlias") != interface_name:
setattr(interface, netbox_key, librenms_interface.get(librenms_key))
else:
setattr(interface, netbox_key, librenms_interface.get(librenms_key))
port_id = librenms_interface.get("port_id")
if port_id is not None:
server_key = getattr(self, "_post_server_key", None) or self.librenms_api.server_key
set_librenms_device_id(interface, port_id, server_key)
if "enabled" not in exclude_columns:
admin_status = librenms_interface.get("ifAdminStatus")
interface.enabled = (
True
if admin_status is None
else (admin_status.lower() == "up" if isinstance(admin_status, str) else bool(admin_status))
)
if "mac_address" not in exclude_columns:
ifPhysAddress = librenms_interface.get("ifPhysAddress")
self.handle_mac_address(interface, ifPhysAddress)
interface.save()
def _sync_interface_vlans(self, interface, librenms_port, interface_name):
"""
Sync VLAN assignments from LibreNMS to NetBox interface.
Sets mode, untagged_vlan, and tagged_vlans based on LibreNMS data.
Args:
interface: NetBox Interface or VMInterface object
librenms_port: Port data dict from LibreNMS with VLAN info
interface_name: Original interface name for form field lookup
"""
# Get per-VLAN group selections from form (safely handle special chars in name)
safe_name = interface_name.replace("/", "_").replace(":", "_")
# Build VLAN data from port
vlan_data = {
"untagged_vlan": librenms_port.get("untagged_vlan"),
"tagged_vlans": librenms_port.get("tagged_vlans", []),
}
# Build per-VLAN group map from POST data
vlan_group_map = {}
all_vids = []
if vlan_data["untagged_vlan"]:
all_vids.append(str(vlan_data["untagged_vlan"]))
for vid in vlan_data.get("tagged_vlans", []):
all_vids.append(str(vid))
for vid in all_vids:
group_id = self.request.POST.get(f"vlan_group_{safe_name}_{vid}", "")
if group_id:
vlan_group_map[vid] = group_id
# Use mixin method to update interface VLAN assignments
self._update_interface_vlan_assignment(interface, vlan_data, vlan_group_map, self._lookup_maps)
class DeleteNetBoxInterfacesView(LibreNMSPermissionMixin, NetBoxObjectPermissionMixin, CacheMixin, View):
"""Delete interfaces that exist only in NetBox."""
def get_required_permissions_for_object_type(self, object_type):
"""Return the required permissions based on object type."""
if object_type == "device":
return [("delete", Interface)]
elif object_type == "virtualmachine":
return [("delete", VMInterface)]
else:
raise Http404(f"Invalid object type: {object_type}")
def post(self, request, object_type, object_id):
"""Delete selected NetBox-only interfaces not present in LibreNMS."""
# Set permissions dynamically based on object type
self.required_object_permissions = {
"POST": self.get_required_permissions_for_object_type(object_type),
}
# Check both plugin write and NetBox object permissions
if error := self.require_all_permissions_json("POST"):
return error
if object_type == "device":
obj = get_object_or_404(Device, pk=object_id)
elif object_type == "virtualmachine":
obj = get_object_or_404(VirtualMachine, pk=object_id)
else:
return JsonResponse({"error": "Invalid object type"}, status=400)
interface_ids = request.POST.getlist("interface_ids")
if not interface_ids:
return JsonResponse({"error": "No interfaces selected for deletion"}, status=400)
deleted_count = 0
errors = []
interface_name = None
try:
with transaction.atomic():
for interface_id in interface_ids:
interface_name = None
try:
if object_type == "device":
interface = Interface.objects.get(id=interface_id)
interface_name = interface.name
if hasattr(obj, "virtual_chassis") and obj.virtual_chassis:
valid_device_ids = [member.id for member in obj.virtual_chassis.members.all()]
if interface.device_id not in valid_device_ids:
errors.append(
"Interface {} does not belong to this device or its virtual chassis".format(
interface.name
)
)
continue
elif interface.device_id != obj.id:
errors.append(f"Interface {interface.name} does not belong to this device")
continue
else:
interface = VMInterface.objects.get(id=interface_id)
interface_name = interface.name
if interface.virtual_machine_id != obj.id:
errors.append(f"Interface {interface.name} does not belong to this virtual machine")
continue
interface.delete()
deleted_count += 1
except (Interface.DoesNotExist, VMInterface.DoesNotExist):
errors.append(f"Interface with ID {interface_id} not found")
continue
except Exception as exc: # pragma: no cover - defensive
errors.append(f"Error deleting interface {interface_name or interface_id}: {str(exc)}")
continue
except Exception as exc: # pragma: no cover
return JsonResponse({"error": f"Transaction failed: {str(exc)}"}, status=500)
response_data = {
"status": "success",
"deleted_count": deleted_count,
"message": f"Successfully deleted {deleted_count} interface(s)",
}
if errors:
response_data["errors"] = errors
response_data["message"] += f" with {len(errors)} error(s)"
return JsonResponse(response_data)

View File

@@ -0,0 +1,160 @@
from urllib.parse import quote_plus
from dcim.models import Device, Interface
from django.contrib import messages
from django.core.cache import cache
from django.db import transaction
from django.http import Http404
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from django.views import View
from ipam.models import VRF, IPAddress
from virtualization.models import VirtualMachine, VMInterface
from netbox_librenms_plugin.views.mixins import (
CacheMixin,
LibreNMSAPIMixin,
LibreNMSPermissionMixin,
NetBoxObjectPermissionMixin,
)
class SyncIPAddressesView(LibreNMSPermissionMixin, NetBoxObjectPermissionMixin, LibreNMSAPIMixin, CacheMixin, View):
"""Synchronize IP addresses from LibreNMS cache into NetBox."""
required_object_permissions = {
"POST": [
("add", IPAddress),
("change", IPAddress),
],
}
def get_selected_ips(self, request):
"""Return selected IP addresses from POST data."""
return [x for x in request.POST.getlist("select") if x]
def get_vrf_selection(self, request, ip_address):
"""Return the VRF selected for a given IP address, or None."""
vrf_id = request.POST.get(f"vrf_{ip_address}")
if vrf_id:
try:
return VRF.objects.get(pk=vrf_id)
except VRF.DoesNotExist:
pass
return None
def get_cached_ip_data(self, request, obj):
"""Return cached LibreNMS IP address data for the given object."""
server_key = getattr(self, "_post_server_key", None) or self.librenms_api.server_key
cached_data = cache.get(self.get_cache_key(obj, "ip_addresses", server_key))
if not cached_data:
return None
return cached_data.get("ip_addresses", [])
def get_object(self, object_type, pk):
"""Return the Device or VirtualMachine instance for the given type and pk."""
if object_type == "device":
return get_object_or_404(Device, pk=pk)
if object_type == "virtualmachine":
return get_object_or_404(VirtualMachine, pk=pk)
raise Http404("Invalid object type.")
def get_ip_tab_url(self, obj):
"""Return the URL for the IP addresses sync tab."""
if isinstance(obj, Device):
url_name = "plugins:netbox_librenms_plugin:device_librenms_sync"
else:
url_name = "plugins:netbox_librenms_plugin:vm_librenms_sync"
server_key = getattr(self, "_post_server_key", None) or self.librenms_api.server_key
url = f"{reverse(url_name, args=[obj.pk])}?tab=ipaddresses"
if server_key:
url += f"&server_key={quote_plus(server_key)}"
return url
def post(self, request, object_type, pk):
"""Sync selected IP addresses from LibreNMS into NetBox."""
# Check both plugin write and NetBox object permissions
if error := self.require_all_permissions("POST"):
return error
# Read server_key from POST so we use the exact server the user was viewing
self._post_server_key = request.POST.get("server_key") or self.librenms_api.server_key
obj = self.get_object(object_type, pk)
selected_ips = self.get_selected_ips(request)
cached_ips = self.get_cached_ip_data(request, obj)
if not cached_ips:
messages.error(request, "Cache has expired. Please refresh the IP data.")
return redirect(self.get_ip_tab_url(obj))
if not selected_ips:
messages.error(request, "No IP addresses selected for synchronization.")
return redirect(self.get_ip_tab_url(obj))
results = self.process_ip_sync(request, selected_ips, cached_ips, obj, object_type)
self.display_sync_results(request, results)
return redirect(self.get_ip_tab_url(obj))
def process_ip_sync(self, request, selected_ips, cached_ips, obj, object_type):
"""Create or update IP addresses in NetBox from cached LibreNMS data."""
results = {"created": [], "updated": [], "unchanged": [], "failed": []}
with transaction.atomic():
for ip_address in selected_ips:
try:
ip_data = next(ip for ip in cached_ips if ip["ip_address"] == ip_address)
vrf = self.get_vrf_selection(request, ip_address)
interface = None
if ip_data.get("interface_url"):
interface_id = ip_data["interface_url"].split("/")[-2]
if object_type == "device":
interface = Interface.objects.get(id=interface_id)
else:
interface = VMInterface.objects.get(id=interface_id)
ip_with_mask = ip_data["ip_with_mask"]
existing_ip = IPAddress.objects.filter(address=ip_with_mask).first()
if existing_ip:
if existing_ip.assigned_object != interface or existing_ip.vrf != vrf:
existing_ip.assigned_object = interface
existing_ip.vrf = vrf
existing_ip.save()
results["updated"].append(ip_address)
else:
results["unchanged"].append(ip_address)
else:
IPAddress.objects.create(
address=ip_with_mask,
assigned_object=interface,
status="active",
vrf=vrf,
)
results["created"].append(ip_address)
except Exception: # pragma: no cover - defensive
results["failed"].append(ip_address)
return results
def display_sync_results(self, request, results):
"""Display flash messages summarizing the IP sync results."""
if results["created"]:
messages.success(request, f"Created IP addresses: {', '.join(results['created'])}")
if results["updated"]:
messages.success(request, f"Updated IP addresses: {', '.join(results['updated'])}")
if results["unchanged"]:
messages.warning(
request,
f"IP addresses already exist: {', '.join(results['unchanged'])}",
)
if results["failed"]:
messages.error(request, f"Failed to sync IP addresses: {', '.join(results['failed'])}")

View File

@@ -0,0 +1,170 @@
from collections import namedtuple
from dcim.models import Site
from django.contrib import messages
from django.core.exceptions import ObjectDoesNotExist
from django.shortcuts import redirect
from django_tables2 import SingleTableView
from netbox_librenms_plugin.filtersets import SiteLocationFilterSet
from netbox_librenms_plugin.tables.locations import SiteLocationSyncTable
from netbox_librenms_plugin.views.mixins import LibreNMSAPIMixin, LibreNMSPermissionMixin
class SyncSiteLocationView(LibreNMSPermissionMixin, LibreNMSAPIMixin, SingleTableView):
"""Synchronize NetBox Sites with LibreNMS locations."""
table_class = SiteLocationSyncTable
template_name = "netbox_librenms_plugin/site_location_sync.html"
filterset = SiteLocationFilterSet
COORDINATE_TOLERANCE = 0.0001
SyncData = namedtuple("SyncData", ["netbox_site", "librenms_location", "is_synced"])
def get_table(self, *args, **kwargs):
"""Return the configured sync table."""
table = super().get_table(*args, **kwargs)
table.configure(self.request)
return table
def get_context_data(self, **kwargs):
"""Return context with filter form for site-location sync."""
context = super().get_context_data(**kwargs)
queryset = self.get_queryset()
context["filter_form"] = self.filterset(self.request.GET, queryset=queryset).form
return context
def get_queryset(self):
"""Return sync data pairing NetBox sites with LibreNMS locations."""
netbox_sites = Site.objects.all()
success, librenms_locations = self.get_librenms_locations()
if not success or not isinstance(librenms_locations, list):
return []
sync_data = [self.create_sync_data(site, librenms_locations) for site in netbox_sites]
if self.request.GET and self.filterset:
return self.filterset(self.request.GET, queryset=sync_data).qs
return sync_data
def get_librenms_locations(self):
"""Fetch all locations from LibreNMS."""
return self.librenms_api.get_locations()
def create_sync_data(self, site, librenms_locations):
"""Create a SyncData tuple pairing a site with its LibreNMS location."""
matched_location = self.match_site_with_location(site, librenms_locations)
if matched_location:
is_synced = self.check_coordinates_match(
site.latitude,
site.longitude,
matched_location.get("lat"),
matched_location.get("lng"),
)
return self.SyncData(site, matched_location, is_synced)
return self.SyncData(site, None, False)
def match_site_with_location(self, site, librenms_locations):
"""Return the LibreNMS location matching the given site, or None."""
for location in librenms_locations:
if location["location"].lower() == site.name.lower() or location["location"].lower() == site.slug.lower():
return location
return None
def check_coordinates_match(self, site_lat, site_lng, librenms_lat, librenms_lng):
"""Return True if site and LibreNMS coordinates match within tolerance."""
if None in (site_lat, site_lng, librenms_lat, librenms_lng):
return False
lat_match = abs(float(site_lat) - float(librenms_lat)) < self.COORDINATE_TOLERANCE
lng_match = abs(float(site_lng) - float(librenms_lng)) < self.COORDINATE_TOLERANCE
return lat_match and lng_match
def post(self, request):
"""Handle create or update of a LibreNMS location from a NetBox site."""
# Check write permission before modifying LibreNMS locations
if error := self.require_write_permission():
return error
action = request.POST.get("action")
pk = request.POST.get("pk")
if not pk:
messages.error(request, "No site ID provided.")
return redirect("plugins:netbox_librenms_plugin:site_location_sync")
site = self.get_site_by_pk(pk)
if not site:
messages.error(request, "Site not found.")
return redirect("plugins:netbox_librenms_plugin:site_location_sync")
if action == "update":
return self.update_librenms_location(request, site)
if action == "create":
return self.create_librenms_location(request, site)
messages.error(request, f"Unknown action '{action}'.")
return redirect("plugins:netbox_librenms_plugin:site_location_sync")
def get_site_by_pk(self, pk):
"""Return the Site for the given pk, or None if not found."""
try:
return Site.objects.get(pk=pk)
except ObjectDoesNotExist:
return None
def create_librenms_location(self, request, site):
"""Create a new location in LibreNMS from the given site."""
if site.latitude is None or site.longitude is None:
messages.warning(
request,
f"Latitude and/or longitude is missing. Cannot create location '{site.name}' in LibreNMS.",
)
return redirect("plugins:netbox_librenms_plugin:site_location_sync")
location_data = self.build_location_data(site)
success, message = self.librenms_api.add_location(location_data)
if success:
messages.success(request, f"Location '{site.name}' created in LibreNMS successfully.")
else:
messages.error(
request,
f"Failed to create location '{site.name}' in LibreNMS: {message}",
)
return redirect("plugins:netbox_librenms_plugin:site_location_sync")
def update_librenms_location(self, request, site):
"""Update an existing LibreNMS location with the site coordinates."""
if site.latitude is None or site.longitude is None:
messages.warning(
request,
f"Latitude and/or longitude is missing. Cannot update location '{site.name}' in LibreNMS.",
)
return redirect("plugins:netbox_librenms_plugin:site_location_sync")
success, librenms_locations = self.get_librenms_locations()
if not success:
messages.error(request, "Failed to retrieve LibreNMS locations.")
return redirect("plugins:netbox_librenms_plugin:site_location_sync")
matched_location = self.match_site_with_location(site, librenms_locations)
if not matched_location:
messages.error(request, f"Could not find matching location for site '{site.name}'")
return redirect("plugins:netbox_librenms_plugin:site_location_sync")
location_data = self.build_location_data(site, include_name=False)
success, message = self.librenms_api.update_location(matched_location["location"], location_data)
if success:
messages.success(request, f"Location '{site.name}' updated in LibreNMS successfully.")
else:
messages.error(
request,
f"Failed to update location '{site.name}' in LibreNMS: {message}",
)
return redirect("plugins:netbox_librenms_plugin:site_location_sync")
def build_location_data(self, site, include_name=True):
"""Build a location data dict from the given site."""
data = {"lat": str(site.latitude), "lng": str(site.longitude)}
if include_name:
data["location"] = site.name
return data

View File

@@ -0,0 +1,175 @@
from urllib.parse import quote_plus
from dcim.models import Device
from django.contrib import messages
from django.core.cache import cache
from django.db import transaction
from django.http import Http404
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from django.views import View
from ipam.models import VLAN, VLANGroup
from netbox_librenms_plugin.views.mixins import (
CacheMixin,
LibreNMSAPIMixin,
LibreNMSPermissionMixin,
NetBoxObjectPermissionMixin,
)
class SyncVLANsView(LibreNMSPermissionMixin, NetBoxObjectPermissionMixin, LibreNMSAPIMixin, CacheMixin, View):
"""
Handle POST requests to create/update VLANs in NetBox from LibreNMS data.
"""
required_object_permissions = {
"POST": [
("add", VLAN),
("change", VLAN),
],
}
def post(self, request, object_type: str, object_id: int):
"""
Process sync request.
Expected POST data:
- action: 'create_vlans'
- select: List of VLAN IDs to create
- vlan_group_{vid}: Per-row VLAN group selection
"""
# Check both plugin write and NetBox object permissions
if error := self.require_all_permissions("POST"):
return error
# Read server_key from POST so we use the exact server the user was viewing
self._post_server_key = request.POST.get("server_key") or self.librenms_api.server_key
obj = self.get_object(object_type, object_id)
action = request.POST.get("action", "")
if action == "create_vlans":
return self._handle_create_vlans(request, obj, object_type, object_id)
else:
messages.error(request, "Invalid action specified.")
return self._redirect(object_type, object_id)
def get_object(self, object_type: str, object_id: int):
"""Get the target object (Device or VM)."""
if object_type == "device":
return get_object_or_404(Device, pk=object_id)
raise Http404("Invalid object type.")
def _redirect(self, object_type: str, object_id: int):
"""Redirect back to sync page with VLAN tab active."""
url_name = (
"dcim:device_librenms_sync"
if object_type == "device"
else "plugins:netbox_librenms_plugin:vm_librenms_sync"
)
server_key = getattr(self, "_post_server_key", None) or self.librenms_api.server_key
url = reverse(url_name, kwargs={"pk": object_id}) + "?tab=vlans"
if server_key:
url += f"&server_key={quote_plus(server_key)}"
return redirect(url)
def _handle_create_vlans(self, request, obj, object_type, object_id):
"""
Handle creating selected VLANs in NetBox.
Reads per-row VLAN group selections from form fields named 'vlan_group_{vid}'.
"""
selected_vlans = request.POST.getlist("select")
if not selected_vlans:
messages.error(request, "No VLANs selected for creation.")
return self._redirect(object_type, object_id)
# Get cached VLAN data
cached_vlans = cache.get(self.get_cache_key(obj, "vlans", self._post_server_key))
if not cached_vlans:
messages.error(request, "No cached VLAN data. Please refresh VLANs first.")
return self._redirect(object_type, object_id)
# Build lookup of LibreNMS VLANs by VID
librenms_vlans = {str(v["vlan_vlan"]): v for v in cached_vlans}
created_count = 0
updated_count = 0
skipped_count = 0
with transaction.atomic():
for vid_str in selected_vlans:
try:
vid = int(vid_str)
except ValueError:
continue
vlan_data = librenms_vlans.get(vid_str)
if not vlan_data:
continue
# Get per-row VLAN group selection
group_id_str = request.POST.get(f"vlan_group_{vid}", "")
row_vlan_group = None
if group_id_str:
try:
row_vlan_group = VLANGroup.objects.get(pk=int(group_id_str))
except (ValueError, VLANGroup.DoesNotExist):
pass # Fall back to global VLAN (no group)
librenms_name = vlan_data.get("vlan_name", f"VLAN {vid}")
if row_vlan_group:
# Grouped VLAN: match by VID (unique constraint within group)
vlan, created = VLAN.objects.get_or_create(
vid=vid,
group=row_vlan_group,
defaults={
"name": librenms_name,
"status": "active",
},
)
if created:
created_count += 1
elif vlan.name != librenms_name:
vlan.name = librenms_name
vlan.save()
updated_count += 1
else:
skipped_count += 1
else:
# Global VLAN: match by VID only (unique constraint with group=NULL)
vlan, created = VLAN.objects.get_or_create(
vid=vid,
group=None,
defaults={
"name": librenms_name,
"status": "active",
},
)
if created:
created_count += 1
elif vlan.name != librenms_name:
vlan.name = librenms_name
vlan.save()
updated_count += 1
else:
skipped_count += 1
# Build summary message
parts = []
if created_count > 0:
parts.append(f"{created_count} created")
if updated_count > 0:
parts.append(f"{updated_count} updated")
if skipped_count > 0:
parts.append(f"{skipped_count} unchanged")
if parts:
messages.success(request, f"VLANs synced: {', '.join(parts)}.")
else:
messages.warning(request, "No VLANs were created or updated.")
return self._redirect(object_type, object_id)