first commit
This commit is contained in:
0
netbox_librenms_plugin/views/sync/__init__.py
Normal file
0
netbox_librenms_plugin/views/sync/__init__.py
Normal file
237
netbox_librenms_plugin/views/sync/cables.py
Normal file
237
netbox_librenms_plugin/views/sync/cables.py
Normal 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'])}",
|
||||
)
|
||||
713
netbox_librenms_plugin/views/sync/device_fields.py
Normal file
713
netbox_librenms_plugin/views/sync/device_fields.py
Normal 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)
|
||||
141
netbox_librenms_plugin/views/sync/devices.py
Normal file
141
netbox_librenms_plugin/views/sync/devices.py
Normal 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)
|
||||
398
netbox_librenms_plugin/views/sync/interfaces.py
Normal file
398
netbox_librenms_plugin/views/sync/interfaces.py
Normal 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)
|
||||
160
netbox_librenms_plugin/views/sync/ip_addresses.py
Normal file
160
netbox_librenms_plugin/views/sync/ip_addresses.py
Normal 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'])}")
|
||||
170
netbox_librenms_plugin/views/sync/locations.py
Normal file
170
netbox_librenms_plugin/views/sync/locations.py
Normal 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
|
||||
175
netbox_librenms_plugin/views/sync/vlans.py
Normal file
175
netbox_librenms_plugin/views/sync/vlans.py
Normal 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)
|
||||
Reference in New Issue
Block a user