"""HTMX endpoints and POST handlers for importing LibreNMS devices.""" import json import logging from urllib.parse import parse_qs, urlparse from django.contrib import messages from django.core.cache import cache from django.core.exceptions import PermissionDenied, ValidationError from django.db import transaction from django.http import HttpResponse, JsonResponse from django.shortcuts import redirect, render from django.utils.html import escape from django.views import View from netbox_librenms_plugin.import_utils import ( _determine_device_name, bulk_import_devices, bulk_import_vms, fetch_device_with_cache, get_import_device_cache_key, get_librenms_device_by_id, get_virtual_chassis_data, update_vc_member_suggested_names, validate_device_for_import, ) from netbox_librenms_plugin.import_validation_helpers import ( apply_cluster_to_validation, apply_rack_to_validation, apply_role_to_validation, extract_device_selections, fetch_model_by_id, ) from netbox_librenms_plugin.tables.device_status import DeviceImportTable from netbox_librenms_plugin.utils import resolve_naming_preferences, save_user_pref, set_librenms_device_id from netbox_librenms_plugin.views.mixins import LibreNMSAPIMixin, LibreNMSPermissionMixin, NetBoxObjectPermissionMixin logger = logging.getLogger(__name__) # Actions that require the force checkbox when a device-type mismatch is detected. _FORCE_REQUIRED_ACTIONS = frozenset({"link", "update", "update_serial", "update_type"}) # Actions that operate on Device-only fields and cannot be applied to VMs. _DEVICE_ONLY_ACTIONS = frozenset({"link", "update", "update_serial", "update_type", "sync_serial", "sync_device_type"}) _TRUTHY_VALUES = {"1", "true", "on", "yes"} _FALSY_VALUES = {"0", "false", "off", "no", ""} def _parse_boolish(value) -> bool | None: """Parse common form/query boolean values. Return None when value is unset/unknown.""" if value is None: return None if isinstance(value, bool): return value normalized = str(value).strip().lower() if normalized in _TRUTHY_VALUES: return True if normalized in _FALSY_VALUES: return False return None def _resolve_vc_detection_enabled(request) -> bool: """ Resolve VC detection preference from request payloads. Resolution order: 1. Explicit POST enable_vc_detection 2. Explicit GET enable_vc_detection 3. return_url query param fallback (POST, then GET) 4. Default False """ for source in (request.POST, request.GET): parsed = _parse_boolish(source.get("enable_vc_detection")) if parsed is not None: return parsed for source in (request.POST, request.GET): return_url = source.get("return_url") if not return_url: continue query = parse_qs(urlparse(return_url).query) parsed = _parse_boolish((query.get("enable_vc_detection") or [None])[-1]) if parsed is not None: return parsed # Backward compatibility for legacy URLs that used skip_vc_detection. skip_vc = _parse_boolish((query.get("skip_vc_detection") or [None])[-1]) if skip_vc is not None: return not skip_vc return False def _save_device(device) -> HttpResponse | None: """Call full_clean() then save(). Return an HttpResponse on failure, None on success.""" from django.db import IntegrityError try: device.full_clean() except ValidationError as exc: error_msg = exc.message_dict if hasattr(exc, "message_dict") else str(exc) return HttpResponse(f"Validation error: {escape(str(error_msg))}", status=400) try: device.save() except IntegrityError as exc: return HttpResponse(f"Integrity error: {escape(str(exc))}", status=409) return None def _get_hostname_for_action(request, validation: dict, libre_device: dict) -> str: """ Return the resolved hostname to use when updating a device during a conflict action. Prefer the cached ``resolved_name`` from validation (already computed with the user's naming prefs at validation time). Fall back to computing it fresh from the current request's naming preferences. """ resolved = validation.get("resolved_name") if resolved: return resolved use_sysname, strip_domain = resolve_naming_preferences(request) return _determine_device_name(libre_device, use_sysname=use_sysname, strip_domain=strip_domain) class DeviceImportHelperMixin: """Mixin providing common validation and rendering helpers for device import views.""" def _should_enable_vc_detection(self, device_id: int, request) -> bool: """ Determine if VC detection should be enabled for this request. VC detection is always enabled for role/rack changes and detail views, regardless of the initial user preference. This implements smart caching: 1. If user originally requested VC detection: Uses cached data from initial load 2. If VC data is already cached: Reuses cached data (no API call) 3. Otherwise: Fetches VC data from LibreNMS API and caches it This approach ensures: - Role/rack changes always have VC context available (required for import) - No redundant API calls when VC data is already cached - Consistent VC detection behavior across dropdowns and detail modals - Since role assignment is required before import, VC data is always available by the time bulk import/confirm operations run Args: device_id: LibreNMS device ID request: Django request object Returns: bool: Always returns True to enable VC detection with smart caching """ # Check if user originally requested VC detection vc_requested = _resolve_vc_detection_enabled(request) if vc_requested: # User explicitly enabled it - use it (will use cache if available) return True # Check if VC data is already cached (no API call will be made) from netbox_librenms_plugin.import_utils import _vc_cache_key cache_key = _vc_cache_key(self.librenms_api, device_id) vc_cached = cache.get(cache_key) is not None if vc_cached: # Data already in cache - enable detection (no API call) return True # Not requested and not cached - make API call to get VC data # This handles the case where user didn't initially request it # but is now changing role/rack (so we fetch it now) return True def get_validated_device_with_selections(self, device_id: int, request) -> tuple[dict | None, dict | None, dict]: """ Get LibreNMS device, validate it, and apply user selections. Consolidates the common pattern across all device import update views. Args: device_id: LibreNMS device ID request: Django request object Returns: Tuple of (libre_device, validation, selections) Returns (None, None, selections) if device not found """ selections = extract_device_selections(request, device_id) cluster_id = selections["cluster_id"] is_vm = bool(cluster_id) # Try to use cached device data from table load (eliminates redundant API calls) libre_device = fetch_device_with_cache(device_id, self.librenms_api) if not libre_device: return None, None, selections # Determine if we should enable VC detection for this request # This checks: user preference, cache status, and VM vs Device enable_vc = not is_vm and self._should_enable_vc_detection(device_id, request) # Extract naming preferences: POST data (hx-include) → user pref → plugin settings. use_sysname, strip_domain = resolve_naming_preferences(request) validation = validate_device_for_import( libre_device, import_as_vm=is_vm, api=self.librenms_api if enable_vc else None, include_vc_detection=enable_vc, use_sysname=use_sysname, strip_domain=strip_domain, server_key=self.librenms_api.server_key, ) # Recompute is_vm from validate_device_for_import's own detection # (it may have found an existing VM via hostname/IP lookup) is_vm = bool(validation.get("import_as_vm")) # Apply user selections (cluster, role, rack) to validation _apply_user_selections_to_validation(validation, selections, is_vm) return libre_device, validation, selections def render_device_row(self, request, libre_device: dict, validation: dict, selections: dict): """ Render device import table row with updated validation. Args: request: Django request object libre_device: LibreNMS device data validation: Updated validation dict selections: User selections dict with cluster_id, role_id, rack_id Returns: HttpResponse with rendered device row """ libre_device["_validation"] = validation table = DeviceImportTable([libre_device]) context = { "record": libre_device, "table": table, "cluster_id": selections["cluster_id"], "role_id": selections["role_id"], "rack_id": selections["rack_id"], } return render( request, "netbox_librenms_plugin/htmx/device_import_row.html", context, ) def _apply_user_selections_to_validation( validation: dict, selections: dict, is_vm: bool, ) -> None: """ Apply user-selected cluster, role, and rack to validation dict. This helper consolidates the logic shared across DeviceValidationDetailsView, DeviceRoleUpdateView, DeviceClusterUpdateView, and DeviceRackUpdateView. Args: validation: Validation dict from validate_device_for_import() selections: Dict with keys: cluster_id, role_id, rack_id is_vm: True if importing as VM, False for device Modifies validation dict in-place by applying cluster/role/rack selections. """ from dcim.models import DeviceRole, Rack from virtualization.models import Cluster cluster_id = selections.get("cluster_id") role_id = selections.get("role_id") rack_id = selections.get("rack_id") if is_vm: # Handle cluster selection (VM only) if cluster_id: cluster = fetch_model_by_id(Cluster, cluster_id) if cluster: apply_cluster_to_validation(validation, cluster) # Handle role selection for VM if role_id: role = fetch_model_by_id(DeviceRole, role_id) if role: apply_role_to_validation(validation, role, is_vm=True) else: # Handle role selection for device if role_id: role = fetch_model_by_id(DeviceRole, role_id) if role: apply_role_to_validation(validation, role, is_vm=False) # Handle rack selection (device only, optional) if rack_id: rack = fetch_model_by_id(Rack, rack_id) if rack: apply_rack_to_validation(validation, rack) class BulkImportConfirmView(LibreNMSPermissionMixin, LibreNMSAPIMixin, View): """HTMX view to confirm bulk imports before execution.""" def post(self, request): """Render a confirmation modal for selected devices before bulk import.""" # Check write permission before showing import confirmation if error := self.require_write_permission(): return error post_server_key = (request.POST.get("server_key") or "").strip() if post_server_key: from netbox_librenms_plugin.librenms_api import LibreNMSAPI self._librenms_api = LibreNMSAPI(server_key=post_server_key) device_ids = request.POST.getlist("select") if not device_ids: return HttpResponse( '
Select at least one device.
', status=400, ) use_sysname, strip_domain = resolve_naming_preferences(request) vc_detection_enabled = _resolve_vc_detection_enabled(request) devices = [] errors = [] seen_ids = set() cache_expired_count = 0 for raw_device_id in device_ids: try: device_id = int(raw_device_id) except (TypeError, ValueError): errors.append(f"Invalid device identifier: {raw_device_id}") continue if device_id in seen_ids: continue seen_ids.add(device_id) # Try to use cached device data from table load or role changes libre_device = fetch_device_with_cache(device_id, self.librenms_api) from_cache = libre_device is not None if not from_cache: cache_expired_count += 1 if not libre_device: errors.append(f"Device ID {device_id} not found in LibreNMS") continue selections = extract_device_selections(request, device_id) cluster_id = selections["cluster_id"] role_id = selections["role_id"] rack_id = selections["rack_id"] is_vm = bool(cluster_id) validation = validate_device_for_import( libre_device, import_as_vm=is_vm, api=self.librenms_api, use_sysname=use_sysname, strip_domain=strip_domain, server_key=self.librenms_api.server_key, # Keep confirm modal aligned with import-time behavior: always # detect VC membership so stack members are visible before import. include_vc_detection=True, ) # Recompute is_vm from validation result — the function may have # detected an existing VM via hostname/IP lookup is_vm = bool(validation.get("import_as_vm")) # Mark validation with VC detection flag for proper URL generation in table # Bulk confirm should respect the initial filter's VC detection preference validation["_vc_detection_enabled"] = vc_detection_enabled device_name = validation.get("resolved_name") or f"device-{device_id}" if validation.get("virtual_chassis", {}).get("is_stack") and device_name: validation["virtual_chassis"] = update_vc_member_suggested_names( validation["virtual_chassis"], device_name ) from dcim.models import DeviceRole, Rack from virtualization.models import Cluster role = fetch_model_by_id(DeviceRole, role_id) if role_id else None cluster = fetch_model_by_id(Cluster, cluster_id) if cluster_id else None rack = fetch_model_by_id(Rack, rack_id) if rack_id else None if is_vm: if cluster: apply_cluster_to_validation(validation, cluster) if role: apply_role_to_validation(validation, role, is_vm=True) else: if role: apply_role_to_validation(validation, role, is_vm=False) if rack: apply_rack_to_validation(validation, rack) devices.append( { "device_id": device_id, "device_name": device_name, "validation": validation, "role": role, "cluster": cluster, "rack": rack, "is_vm": is_vm, } ) if not devices: # Check if this is due to cache expiration if cache_expired_count > 0 and cache_expired_count == len(seen_ids): return HttpResponse( '
' ' ' "Filter results have expired.
" "The device data is no longer available in cache (5-minute timeout). " 'Please refresh the page ' "or re-run your filter to reload device data." "
", status=400, ) elif cache_expired_count > 0: # Partial expiration - some devices lost their selections return HttpResponse( '
' ' ' f"Some device data has expired.
" f"{cache_expired_count} of {len(seen_ids)} selected devices had expired cache data and may be missing role/rack selections. " 'Please refresh the page ' "or re-run your filter to reload device data." "
", status=400, ) else: # Generic error - validation failed for all devices return HttpResponse( '
' "No valid devices selected. " f"{len(errors)} error(s) occurred: {' '.join(errors) if errors else 'Please check device validation status.'}" "
", status=400, ) context = { "devices": devices, "device_count": len(devices), "errors": errors, "use_sysname": use_sysname, "strip_domain": strip_domain, "server_key": self.librenms_api.server_key, "vc_detection_enabled": vc_detection_enabled, } return render( request, "netbox_librenms_plugin/htmx/bulk_import_confirm.html", context, ) class BulkImportDevicesView(LibreNMSPermissionMixin, LibreNMSAPIMixin, View): """Handle bulk import requests coming from the LibreNMS import table.""" def should_use_background_job_for_import(self, request): """ Determine if import operation should run as background job. Import jobs provide active cancellation and keep the browser responsive during bulk imports. Note: Non-superusers automatically fall back to synchronous mode because the /api/core/background-tasks/ endpoint requires superuser access. Args: request: Django request object containing POST data Returns: bool: True if background job should be used, False for synchronous """ # Non-superusers cannot poll background-tasks API (requires IsSuperuser) if not request.user.is_superuser: return False return request.POST.get("use_background_job") == "on" def post(self, request): # noqa: PLR0912 - branching keeps responses explicit """Import selected devices from LibreNMS into NetBox.""" # Check write permission before any import operation if error := self.require_write_permission(): return error post_server_key = (request.POST.get("server_key") or "").strip() if post_server_key: from netbox_librenms_plugin.librenms_api import LibreNMSAPI self._librenms_api = LibreNMSAPI(server_key=post_server_key) device_ids = request.POST.getlist("select") if not device_ids: messages.error(request, "No devices selected for import") return HttpResponse("No devices selected", status=400) try: parsed_ids = [int(device_id) for device_id in device_ids] except (TypeError, ValueError): messages.error(request, "Invalid device identifier supplied") return HttpResponse("Invalid device identifier", status=400) use_sysname, strip_domain = resolve_naming_preferences(request) vc_detection_enabled = _resolve_vc_detection_enabled(request) sync_options = { "sync_interfaces": request.POST.get("sync_interfaces") == "on", "sync_cables": request.POST.get("sync_cables") == "on", "sync_ips": request.POST.get("sync_ips") == "on", "vc_detection_enabled": vc_detection_enabled, "use_sysname": use_sysname, "strip_domain": strip_domain, } manual_mappings_per_device: dict[int, dict[str, int]] = {} vm_imports: dict[int, dict[str, int]] = {} # Track which devices to import as VMs for device_id in parsed_ids: mappings = {} cluster_value = request.POST.get(f"cluster_{device_id}") # If cluster is selected, this is a VM import if cluster_value: try: vm_imports[device_id] = {"cluster_id": int(cluster_value)} # VMs can also have roles role_value = request.POST.get(f"role_{device_id}") if role_value: vm_imports[device_id]["device_role_id"] = int(role_value) except (TypeError, ValueError): logger.warning( "Ignoring invalid cluster/role id for VM import of device %s", device_id, ) continue # Skip device-specific mappings for VMs # Device import mappings role_value = request.POST.get(f"role_{device_id}") if role_value: try: mappings["device_role_id"] = int(role_value) except (TypeError, ValueError): logger.warning( "Ignoring invalid role id '%s' for device %s", role_value, device_id, ) rack_value = request.POST.get(f"rack_{device_id}") if rack_value: try: mappings["rack_id"] = int(rack_value) except (TypeError, ValueError): logger.warning( "Ignoring invalid rack id '%s' for device %s", rack_value, device_id, ) if mappings: manual_mappings_per_device[device_id] = mappings # Separate device IDs into device imports vs VM imports device_ids_to_import = [d for d in parsed_ids if d not in vm_imports] vm_ids_to_import = list(vm_imports.keys()) # Build cache of already-fetched device data to avoid redundant API calls libre_devices_cache = {} for device_id in parsed_ids: cached_device = fetch_device_with_cache(device_id, self.librenms_api) if cached_device: libre_devices_cache[device_id] = cached_device # Check if we should use background job for import total_import_count = len(parsed_ids) # Decide whether to use background job if self.should_use_background_job_for_import(request): # Check if RQ workers are available from utilities.rqworker import get_workers_for_queue if get_workers_for_queue("default") > 0: from netbox_librenms_plugin.jobs import ImportDevicesJob # Enqueue background job job = ImportDevicesJob.enqueue( user=request.user, device_ids=device_ids_to_import, vm_imports=vm_imports, server_key=self.librenms_api.server_key, sync_options=sync_options, manual_mappings_per_device=manual_mappings_per_device, libre_devices_cache=libre_devices_cache, ) logger.info( f"Enqueued ImportDevicesJob {job.pk} (UUID: {job.job_id}) for user {request.user} - {total_import_count} devices/VMs" ) # Show notification and redirect - matching NetBox's native pattern from django.utils.safestring import mark_safe messages.info( request, mark_safe( f"Import job started for {total_import_count} device{'s' if total_import_count != 1 else ''}. " f'You can monitor progress in the Jobs interface.' ), ) if request.headers.get("HX-Request"): # For HTMX requests, redirect to clean import page (no filters) # This matches the "Clear" button behavior return HttpResponse( "", headers={"HX-Redirect": "/plugins/librenms_plugin/librenms-import/"}, ) else: return redirect("plugins:netbox_librenms_plugin:librenms_import") else: # No workers available - warn user and proceed synchronously logger.warning("No RQ workers available for import job, falling back to synchronous import") messages.warning( request, f"Background job requested but no workers available. Importing {total_import_count} devices synchronously...", ) # Synchronous import execution # Build cache of already-fetched device data to avoid redundant API calls libre_devices_cache_sync = {} for device_id in parsed_ids: cached_device = fetch_device_with_cache(device_id, self.librenms_api) if cached_device: libre_devices_cache_sync[device_id] = cached_device # Import devices and VMs separately device_result = { "success": [], "failed": [], "skipped": [], "virtual_chassis_created": 0, } vm_result = {"success": [], "failed": [], "skipped": []} try: # Import devices if any if device_ids_to_import: device_result = bulk_import_devices( device_ids=device_ids_to_import, server_key=self.librenms_api.server_key, sync_options=sync_options, manual_mappings_per_device=manual_mappings_per_device, # type: ignore libre_devices_cache=libre_devices_cache_sync, user=request.user, # Pass user for permission checks ) # Import VMs if any if vm_ids_to_import: vm_result = bulk_import_vms( vm_imports, self.librenms_api, sync_options, libre_devices_cache_sync, user=request.user, # Pass user for permission checks ) except PermissionDenied as exc: # Handle permission errors with a user-friendly message logger.warning(f"Permission denied during import: {exc}") messages.error(request, str(exc)) if request.headers.get("HX-Request"): return HttpResponse( "", headers={"HX-Redirect": "/plugins/librenms_plugin/librenms-import/"}, ) return redirect("plugins:netbox_librenms_plugin:librenms_import") except Exception as exc: # pragma: no cover - defensive guard logger.exception("Error during bulk import") if request.headers.get("HX-Request"): return HttpResponse(str(exc), status=500) messages.error(request, f"Bulk import failed: {exc}") return redirect("plugins:netbox_librenms_plugin:librenms_import") # Combine results success_count = len(device_result.get("success", [])) + len(vm_result.get("success", [])) failed_count = len(device_result.get("failed", [])) + len(vm_result.get("failed", [])) skipped_count = len(device_result.get("skipped", [])) + len(vm_result.get("skipped", [])) if success_count: messages.success( request, f"Successfully imported {success_count} LibreNMS device{'s' if success_count != 1 else ''}", ) if failed_count: messages.error( request, f"Failed to import {failed_count} device{'s' if failed_count != 1 else ''}", ) if skipped_count: messages.warning( request, f"Skipped {skipped_count} existing device{'s' if skipped_count != 1 else ''}", ) if request.headers.get("HX-Request"): # Return updated rows for all imported devices using HTMX OOB swaps # This updates only the affected rows instead of refreshing the entire table updated_rows_html = [] # Collect all successfully imported device IDs (devices + VMs) imported_device_ids = [item["device_id"] for item in device_result.get("success", [])] + [ item["device_id"] for item in vm_result.get("success", []) ] # Re-validate and render each imported device with fresh status for device_id in imported_device_ids: # Fetch device from cache or API libre_device = fetch_device_with_cache( device_id, self.librenms_api, libre_devices_cache=libre_devices_cache_sync, ) if libre_device: # Determine if this was imported as VM or device is_vm = device_id in [item["device_id"] for item in vm_result.get("success", [])] # Re-validate with fresh status (will now show as imported) # Pass naming preferences so name comparison uses the same # resolved name the device was imported with. validation = validate_device_for_import( libre_device, import_as_vm=is_vm, api=None, # No VC detection needed for already-imported devices include_vc_detection=False, server_key=self.librenms_api.server_key, use_sysname=sync_options.get("use_sysname", True), strip_domain=sync_options.get("strip_domain", False), ) validation["import_as_vm"] = is_vm # Update cache with fresh validation libre_device["_validation"] = validation cache_key = get_import_device_cache_key(device_id, self.librenms_api.server_key) cache.set(cache_key, libre_device, self.librenms_api.cache_timeout) # Render updated row table = DeviceImportTable([libre_device]) context = { "record": libre_device, "table": table, "cluster_id": None, "role_id": None, "rack_id": None, } row_html = render( request, "netbox_librenms_plugin/htmx/device_import_row.html", context, ).content.decode("utf-8") updated_rows_html.append(row_html) # Return concatenated row HTML with closeModal trigger return HttpResponse( "\n".join(updated_rows_html), headers={"HX-Trigger": '{"closeModal": null}'}, ) return redirect("plugins:netbox_librenms_plugin:librenms_import") class DeviceVCDetailsView(LibreNMSPermissionMixin, LibreNMSAPIMixin, View): """HTMX view to show virtual chassis details.""" def get(self, request, device_id): """Render virtual chassis details for a LibreNMS device.""" libre_device = get_librenms_device_by_id(self.librenms_api, device_id) if not libre_device: return HttpResponse( '
Device not found in LibreNMS
', status=404, ) vc_data = get_virtual_chassis_data(self.librenms_api, device_id) context = { "libre_device": libre_device, "vc_data": vc_data, } return render( request, "netbox_librenms_plugin/htmx/device_vc_details.html", context, ) class DeviceValidationDetailsView(LibreNMSPermissionMixin, LibreNMSAPIMixin, DeviceImportHelperMixin, View): """HTMX view to show detailed validation information.""" def get(self, request, device_id): """Render detailed validation information for a LibreNMS device.""" libre_device, validation, selections = self.get_validated_device_with_selections(device_id, request) if not libre_device: return HttpResponse( '
Device not found in LibreNMS
', status=404, ) use_sysname, strip_domain = resolve_naming_preferences(request) context = { "libre_device": libre_device, "validation": validation, "use_sysname": use_sysname, "strip_domain": strip_domain, "server_key": self.librenms_api.server_key, } # Add sync comparison data for existing devices existing = validation.get("existing_device") if existing: context["sync_info"] = self._build_sync_info(libre_device, existing) context["existing_id_servers"] = self._build_id_server_info(existing) context["existing_device_model_name"] = existing._meta.model_name return render( request, "netbox_librenms_plugin/htmx/device_validation_details.html", context, ) @staticmethod def _build_sync_info(libre_device, existing_device): """Build sync comparison data between LibreNMS device and existing NetBox device.""" librenms_serial = libre_device.get("serial") or "-" librenms_os = libre_device.get("os") or "-" librenms_hardware = libre_device.get("hardware") or "-" # Serial comparison (VMs may not have serial in all NetBox versions) netbox_serial = getattr(existing_device, "serial", None) or "" serial_synced = netbox_serial == librenms_serial or librenms_serial == "-" # Platform comparison platform_info = { "netbox_platform": getattr(existing_device, "platform", None), "librenms_os": librenms_os, "platform_exists": False, "matching_platform": None, } if librenms_os and librenms_os != "-": from netbox_librenms_plugin.utils import find_matching_platform match_result = find_matching_platform(librenms_os) if match_result["found"]: platform_info["platform_exists"] = True platform_info["matching_platform"] = match_result["platform"] netbox_platform = platform_info["netbox_platform"] matching_platform = platform_info["matching_platform"] platform_synced = librenms_os == "-" or bool( netbox_platform and matching_platform and netbox_platform.pk == matching_platform.pk ) # Device type comparison (VMs don't have device_type) device_type_synced = True librenms_device_type = None from virtualization.models import VirtualMachine if not isinstance(existing_device, VirtualMachine): netbox_device_type = getattr(existing_device, "device_type", None) if librenms_hardware and librenms_hardware != "-": from netbox_librenms_plugin.utils import match_librenms_hardware_to_device_type hw_match = match_librenms_hardware_to_device_type(librenms_hardware) if hw_match is None: device_type_synced = False elif hw_match.get("matched"): librenms_device_type = hw_match["device_type"] if netbox_device_type is None or netbox_device_type.pk != librenms_device_type.pk: device_type_synced = False else: device_type_synced = False all_synced = serial_synced and platform_synced and device_type_synced return { "librenms_serial": librenms_serial, "serial_synced": serial_synced, "platform_info": platform_info, "platform_synced": platform_synced, "librenms_hardware": librenms_hardware, "librenms_device_type": librenms_device_type, "device_type_synced": device_type_synced, "all_synced": all_synced, } @staticmethod def _build_id_server_info(existing_device): """ Return per-server ID mappings for the existing device's librenms_id custom field. Returns a list of dicts with server_key, display_name, and device_id — one entry per server the device is linked to. Returns None when the format is legacy (bare int) or when the field is absent/invalid. """ from django.conf import settings cf_value = existing_device.custom_field_data.get("librenms_id") if not isinstance(cf_value, dict): return None plugins_config = settings.PLUGINS_CONFIG.get("netbox_librenms_plugin") or {} servers_config = plugins_config.get("servers") or {} if not isinstance(servers_config, dict): servers_config = {} result = [] for sk, did in cf_value.items(): if isinstance(did, bool) or not isinstance(did, (int, str)): continue if isinstance(did, str): if not did.isdigit(): continue did = int(did) srv_cfg = servers_config.get(sk) # Legacy single-server config: "default" key with no matching servers entry — # fall back to root-level display_name in plugins_config. if srv_cfg is None and sk == "default" and not servers_config: display_name = plugins_config.get("display_name") or sk else: if not isinstance(srv_cfg, dict): srv_cfg = {} display_name = srv_cfg.get("display_name") or sk result.append({"server_key": sk, "display_name": display_name, "device_id": did}) return result or None class DeviceRoleUpdateView(LibreNMSPermissionMixin, LibreNMSAPIMixin, DeviceImportHelperMixin, View): """HTMX view to update a table row when a role is selected.""" def post(self, request, device_id): """Update the table row after a device role selection change.""" libre_device, validation, selections = self.get_validated_device_with_selections(device_id, request) if not libre_device: return HttpResponse("Device not found", status=404) return self.render_device_row(request, libre_device, validation, selections) class DeviceClusterUpdateView(LibreNMSPermissionMixin, LibreNMSAPIMixin, DeviceImportHelperMixin, View): """HTMX view to update a table row when a cluster is selected/deselected.""" def post(self, request, device_id): """Update the table row after a cluster selection change.""" libre_device, validation, selections = self.get_validated_device_with_selections(device_id, request) if not libre_device: return HttpResponse("Device not found", status=404) return self.render_device_row(request, libre_device, validation, selections) class DeviceRackUpdateView(LibreNMSPermissionMixin, LibreNMSAPIMixin, DeviceImportHelperMixin, View): """HTMX view to update a table row when a rack is selected.""" def post(self, request, device_id): """Update the table row after a rack selection change.""" libre_device, validation, selections = self.get_validated_device_with_selections(device_id, request) if not libre_device: return HttpResponse("Device not found", status=404) return self.render_device_row(request, libre_device, validation, selections) class DeviceConflictActionView( LibreNMSPermissionMixin, NetBoxObjectPermissionMixin, LibreNMSAPIMixin, DeviceImportHelperMixin, View ): """HTMX view to resolve device conflicts (link, update, update serial).""" def post(self, request, device_id): """Resolve a device conflict by linking, updating, or syncing serial.""" if error := self.require_write_permission(): return error from dcim.models import Device from netbox_librenms_plugin.librenms_api import LibreNMSAPI action = request.POST.get("action") existing_device_id = request.POST.get("existing_device_id") existing_device_type = request.POST.get("existing_device_type", "device") # If the form submitted a specific server_key, honour it so the handler uses # the same server context as the import page when the user clicked the button. post_server_key = (request.POST.get("server_key") or "").strip() if post_server_key: self._librenms_api = LibreNMSAPI(server_key=post_server_key) if not action or not existing_device_id: return HttpResponse("Missing action or existing_device_id", status=400) # VirtualMachine supports migrate_librenms_id, sync_name, and sync_platform. # Device-only actions (serial, device_type, legacy link/update) are rejected. if existing_device_type == "virtualmachine": if action in _DEVICE_ONLY_ACTIONS: return HttpResponse( f"Action '{escape(action)}' is not supported for virtual machines", status=400, ) from virtualization.models import VirtualMachine as NetBoxVM existing_model: type = NetBoxVM else: existing_model = Device try: existing_device = existing_model.objects.get(pk=int(existing_device_id)) except (existing_model.DoesNotExist, ValueError): return HttpResponse("Existing device not found", status=404) # Object-level change permission for the specific model being mutated. self.required_object_permissions = {"POST": [("change", existing_model)]} if error := self.require_object_permissions("POST"): return error libre_device, validation, selections = self.get_validated_device_with_selections(device_id, request) if not libre_device: return HttpResponse("LibreNMS device not found", status=404) # Verify the POSTed existing_device_id matches the validated conflict target. # Require a confirmed conflict target: if validation has no existing_device, the # LibreNMS device was not validated against this NetBox device, so mutations are unsafe. validated_existing = validation.get("existing_device") if validation else None if validated_existing is None: return HttpResponse("Missing validated conflict target", status=400) if validated_existing.pk != existing_device.pk or type(validated_existing) is not type(existing_device): return HttpResponse("Device ID mismatch: existing_device_id does not match validated device", status=400) # Require force flag when device type mismatches, but only for actions that use it force = request.POST.get("force") == "on" if validation.get("device_type_mismatch") and action in _FORCE_REQUIRED_ACTIONS and not force: return HttpResponse( "Device type mismatch detected. Check the force checkbox to proceed.", status=400, ) # When force is used with device_type_mismatch, update device type to LibreNMS value librenms_device_type = None if validation.get("device_type_mismatch") and force: librenms_device_type = validation.get("device_type", {}).get("device_type") librenms_id = libre_device.get("device_id") if isinstance(librenms_id, bool): return HttpResponse("Invalid or missing LibreNMS device_id in payload", status=400) try: librenms_id = int(librenms_id) except (TypeError, ValueError): return HttpResponse("Invalid or missing LibreNMS device_id in payload", status=400) if librenms_id <= 0: return HttpResponse("Invalid or missing LibreNMS device_id in payload", status=400) # Wrap the LibreNMS-ID collision check and subsequent write in a single # transaction so the read-then-write is atomic for link/update/update_serial. # NOTE: A fully race-free guarantee would require a DB-unique constraint on # (server_key, librenms_id) — e.g., a dedicated DeviceLibreNMSIDMapping model. # That is deferred to a future schema migration. Until then, we acquire a # row-level lock on the target device before re-checking for conflicts, which # serializes concurrent operations on the SAME device and greatly reduces the # window for assigning the same ID to two DIFFERENT devices. if action in {"link", "update", "update_serial"}: from netbox_librenms_plugin.utils import find_by_librenms_id with transaction.atomic(): server_key = self.librenms_api.server_key # Lock the target device row so concurrent requests for the same # device are serialized. The conflict check below is still a # best-effort guard for different devices; a DB unique constraint # would be needed for full protection. try: existing_device = Device.objects.select_for_update().get(pk=existing_device.pk) except Device.DoesNotExist: return HttpResponse( "Device no longer exists; it may have been deleted concurrently.", status=409, ) id_conflict = find_by_librenms_id(Device, int(librenms_id), server_key) if id_conflict and id_conflict.pk != existing_device.pk: return HttpResponse( f"LibreNMS ID conflict: ID {escape(str(librenms_id))} is already assigned to device " f"'{escape(id_conflict.name)}' (ID: {id_conflict.pk})", status=409, ) # Reject legacy bare-int/string librenms_id: set_librenms_device_id # silently skips writes for legacy formats, leaving the device partially # updated. User must run "Convert mapping" migration first. stored_id = existing_device.custom_field_data.get("librenms_id") _is_legacy = isinstance(stored_id, int) and not isinstance(stored_id, bool) if not _is_legacy and isinstance(stored_id, str): try: int(stored_id) _is_legacy = True except (ValueError, TypeError): pass if _is_legacy: return HttpResponse( "Device has a legacy bare-integer librenms_id; use 'Convert mapping' " "to migrate to the multi-server format before linking.", status=409, ) if action == "link": # Link to LibreNMS and update name from LibreNMS data hostname = _get_hostname_for_action(request, validation, libre_device) set_librenms_device_id(existing_device, librenms_id, self.librenms_api.server_key) existing_device.name = hostname if librenms_device_type: existing_device.device_type = librenms_device_type if err := _save_device(existing_device): return err logger.info(f"Linked device '{existing_device.name}' to LibreNMS ID {librenms_id}") elif action == "update": # Update hostname, serial, and link to LibreNMS hostname = _get_hostname_for_action(request, validation, libre_device) incoming_serial = libre_device.get("serial") or "" if incoming_serial and incoming_serial != "-": # Lock any conflicting device under the same transaction to reduce # the serial-assignment race window (best-effort; a DB unique # constraint on serial would give full protection). conflict_device = ( Device.objects.select_for_update() .filter(serial=incoming_serial) .exclude(pk=existing_device.pk) .first() ) if conflict_device: return HttpResponse( f"Serial conflict: '{escape(incoming_serial)}' is already assigned to device " f"'{escape(conflict_device.name)}' (ID: {conflict_device.pk})", status=409, ) existing_device.serial = incoming_serial existing_device.name = hostname if librenms_device_type: existing_device.device_type = librenms_device_type set_librenms_device_id(existing_device, librenms_id, self.librenms_api.server_key) if err := _save_device(existing_device): return err logger.info( f"Updated device '{existing_device.name}': serial={incoming_serial}, " f"linked to LibreNMS ID {librenms_id}" ) elif action == "update_serial": # Update only the serial and link to LibreNMS incoming_serial = libre_device.get("serial") or "" if incoming_serial and incoming_serial != "-": # Lock any conflicting device under the same transaction to reduce # the serial-assignment race window (best-effort; a DB unique # constraint on serial would give full protection). conflict_device = ( Device.objects.select_for_update() .filter(serial=incoming_serial) .exclude(pk=existing_device.pk) .first() ) if conflict_device: return HttpResponse( f"Serial conflict: '{escape(incoming_serial)}' is already assigned to device " f"'{escape(conflict_device.name)}' (ID: {conflict_device.pk})", status=409, ) existing_device.serial = incoming_serial if librenms_device_type: existing_device.device_type = librenms_device_type set_librenms_device_id(existing_device, librenms_id, self.librenms_api.server_key) if err := _save_device(existing_device): return err logger.info( f"Updated serial on device '{existing_device.name}' to {incoming_serial}, " f"linked to LibreNMS ID {librenms_id}" ) elif action == "sync_name": # Sync device name from LibreNMS (e.g., IP → sysName) hostname = _get_hostname_for_action(request, validation, libre_device) existing_device.name = hostname if err := _save_device(existing_device): return err logger.info(f"Synced name on device '{existing_device.name}' from LibreNMS") elif action == "update_type": # Update device type from LibreNMS (requires force for mismatch) if librenms_device_type: existing_device.device_type = librenms_device_type if err := _save_device(existing_device): return err logger.info(f"Updated device type on '{existing_device.name}' to {librenms_device_type}") else: return HttpResponse("No LibreNMS device type available to update", status=400) elif action == "sync_serial": # Sync serial number from LibreNMS. # Wrap conflict-check-and-write in a transaction with a row lock so # concurrent requests cannot both pass the serial uniqueness guard. incoming_serial = libre_device.get("serial") or "" if incoming_serial and incoming_serial != "-": with transaction.atomic(): try: locked_device = Device.objects.select_for_update().get(pk=existing_device.pk) except Device.DoesNotExist: return HttpResponse( "Device no longer exists; it may have been deleted concurrently.", status=409, ) # Re-check for serial ownership conflict under lock. # Note: We intentionally do NOT enforce a DB-level uniqueness constraint on # Device.serial. During device moves/replacements, multiple devices may # temporarily share a serial (old record gets updated later). A unique # constraint would block those valid workflows. Instead, we rely on this # in-transaction row-lock check to guard concurrent sync of the SAME serial, # and flag conflicts via a 409 response for the user to resolve manually. conflict_device = Device.objects.filter(serial=incoming_serial).exclude(pk=locked_device.pk).first() if conflict_device: logger.warning( f"Serial sync blocked: '{incoming_serial}' already assigned to " f"'{conflict_device.name}' (pk={conflict_device.pk})" ) return HttpResponse( f"Serial conflict: '{escape(incoming_serial)}' is already assigned to device " f"'{escape(conflict_device.name)}' (ID: {conflict_device.pk})", status=409, ) locked_device.serial = incoming_serial if err := _save_device(locked_device): return err logger.info(f"Synced serial on '{locked_device.name}' to {incoming_serial}") else: return HttpResponse("No valid serial from LibreNMS", status=400) elif action == "sync_platform": # Sync platform from LibreNMS OS from netbox_librenms_plugin.utils import find_matching_platform librenms_os = libre_device.get("os") or "" if librenms_os and librenms_os != "-": match_result = find_matching_platform(librenms_os) if match_result["found"]: existing_device.platform = match_result["platform"] if err := _save_device(existing_device): return err logger.info(f"Synced platform on '{existing_device.name}' to {match_result['platform']}") else: return HttpResponse(f"Platform '{escape(librenms_os)}' not found in NetBox", status=400) else: return HttpResponse("No OS info from LibreNMS", status=400) elif action == "sync_device_type": # Sync device type from LibreNMS hardware (non-mismatch case) from netbox_librenms_plugin.utils import match_librenms_hardware_to_device_type hardware = libre_device.get("hardware") or "" hw_match = match_librenms_hardware_to_device_type(hardware) if hw_match and hw_match.get("matched"): existing_device.device_type = hw_match["device_type"] if err := _save_device(existing_device): return err logger.info(f"Synced device type on '{existing_device.name}' to {hw_match['device_type']}") else: return HttpResponse(f"No matching device type for '{escape(hardware)}'", status=400) elif action == "migrate_librenms_id": # Migrate legacy bare-integer librenms_id to the JSON dict format. # Only safe when the integer matches the LibreNMS device ID for this server, # confirmed by serial match (or explicit force). from netbox_librenms_plugin.utils import migrate_legacy_librenms_id # Direct access needed to detect legacy integer format for migration prompt: # LibreNMSAPI.get_librenms_id() returns an int in both formats; only the raw # type check on custom_field_data reveals whether migration is needed. cf_value = existing_device.custom_field_data.get("librenms_id") if isinstance(cf_value, bool) or not ( isinstance(cf_value, int) or (isinstance(cf_value, str) and cf_value.isdigit()) ): return HttpResponse( "Device librenms_id is already in JSON format; no migration needed.", status=400, ) # Normalise string-digit to int for consistent comparison cf_int = int(cf_value) if isinstance(cf_value, str) else cf_value # Verify the stored legacy ID matches the active LibreNMS device_id so we don't # migrate a stale/incorrect association to the wrong server mapping. if cf_int != librenms_id: return HttpResponse( f"Legacy librenms_id ({cf_int}) does not match the active device ID " f"({librenms_id}); cannot migrate safely.", status=400, ) if not validation.get("serial_confirmed") and not force: return HttpResponse( "Serial number not confirmed. Check the force checkbox to migrate without serial verification.", status=400, ) with transaction.atomic(): try: locked_device = existing_model.objects.select_for_update().get(pk=existing_device.pk) except existing_model.DoesNotExist: return HttpResponse( "Object no longer exists; it may have been deleted concurrently.", status=409, ) # Re-check under lock — another request may have already migrated it cf_locked = locked_device.custom_field_data.get("librenms_id") if isinstance(cf_locked, bool) or not ( isinstance(cf_locked, int) or (isinstance(cf_locked, str) and cf_locked.isdigit()) ): return HttpResponse( "Device librenms_id is already in JSON format; no migration needed.", status=400, ) cf_locked_int = int(cf_locked) if isinstance(cf_locked, str) else cf_locked if cf_locked_int != librenms_id: return HttpResponse( f"Legacy librenms_id changed under lock ({cf_locked_int} != {librenms_id}); cannot migrate safely.", status=400, ) # Check that no other object already owns this ID (server-scoped or legacy) server_key = self.librenms_api.server_key from netbox_librenms_plugin.utils import find_by_librenms_id match = find_by_librenms_id(existing_model, cf_locked_int, server_key) conflict = match is not None and match.pk != locked_device.pk if conflict: return HttpResponse( f"Another device already has librenms_id {cf_locked_int} for server '{server_key}'; cannot migrate.", status=409, ) if not migrate_legacy_librenms_id(locked_device, self.librenms_api.server_key): return HttpResponse( "Migration failed: librenms_id could not be converted.", status=400, ) if err := _save_device(locked_device): return err logger.info( f"Migrated legacy librenms_id on '{locked_device.name}' " f"to {{{self.librenms_api.server_key!r}: {cf_locked_int}}}" ) else: return HttpResponse(f"Unknown action: {escape(action)}", status=400) # Clear cached validation so re-validation picks up the changes cache_key = get_import_device_cache_key(device_id, self.librenms_api.server_key) cache.delete(cache_key) # Re-validate and render updated row libre_device, validation, selections = self.get_validated_device_with_selections(device_id, request) if not libre_device: return HttpResponse("Device not found after action", status=404) response = self.render_device_row(request, libre_device, validation, selections) response["HX-Trigger"] = "closeModal" return response class SaveUserPrefView(LibreNMSPermissionMixin, View): """Save a user preference via POST. Used by JS toggle handlers.""" ALLOWED_PREFS = { "use_sysname": "plugins.netbox_librenms_plugin.use_sysname", "strip_domain": "plugins.netbox_librenms_plugin.strip_domain", "interface_name_field": "plugins.netbox_librenms_plugin.interface_name_field", } def post(self, request): """Persist a user preference toggle value.""" try: data = json.loads(request.body) except (json.JSONDecodeError, ValueError): return JsonResponse({"error": "Invalid JSON"}, status=400) key = data.get("key") value = data.get("value") if key not in self.ALLOWED_PREFS: return JsonResponse({"error": "Invalid preference key"}, status=400) save_user_pref(request, self.ALLOWED_PREFS[key], value) return JsonResponse({"status": "ok"})