Files
netbox-librenms-plugin/netbox_librenms_plugin/views/imports/list.py
Vlastislav Svatek 673e67106e
Some checks failed
ci / deploy (push) Has been cancelled
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
first commit
2026-06-05 10:39:05 +02:00

481 lines
21 KiB
Python

import logging
from dcim.models import Device
from django.contrib import messages
from django.core.cache import cache
from django.http import JsonResponse
from django.shortcuts import render
from netbox.views import generic
from utilities.rqworker import get_workers_for_queue
from netbox_librenms_plugin.forms import LibreNMSImportFilterForm
from netbox_librenms_plugin.import_utils import (
get_active_cached_searches,
process_device_filters,
)
from netbox_librenms_plugin.models import LibreNMSSettings
from netbox_librenms_plugin.tables.device_status import DeviceImportTable
from netbox_librenms_plugin.utils import get_user_pref
from netbox_librenms_plugin.views.mixins import LibreNMSAPIMixin, LibreNMSPermissionMixin
logger = logging.getLogger(__name__)
class LibreNMSImportView(LibreNMSPermissionMixin, LibreNMSAPIMixin, generic.ObjectListView):
"""Import devices from LibreNMS into NetBox with validation metadata."""
queryset = Device.objects.none()
table = DeviceImportTable
filterset = None
filterset_form = LibreNMSImportFilterForm
template_name = "netbox_librenms_plugin/librenms_import.html"
actions = {}
title = "Import Devices from LibreNMS"
def get_required_permission(self):
"""Return the permission required to view the import list."""
from utilities.permissions import get_permission_for_model
return get_permission_for_model(Device, "view")
def should_use_background_job(self):
"""
Determine if filter operation should run as background job.
Background jobs provide active cancellation and keep the browser responsive
during long-running operations.
The main benefits of background jobs are:
- Active cancellation capability
- Browser responsiveness (no "page loading" hang)
- Job tracking in NetBox Jobs interface
- Results cached for later retrieval
Note: Non-superusers automatically fall back to synchronous mode because
the /api/core/background-tasks/ endpoint requires superuser access.
Returns:
bool: True if background job should be used, False for synchronous
"""
# Non-superusers cannot poll background-tasks API (requires IsSuperuser)
if not self.request.user.is_superuser:
return False
return self._filter_form_data.get("use_background_job", True)
def _load_job_results(self, job_id):
"""
Load cached results from a completed background job.
Args:
job_id: ID of the completed FilterDevicesJob
Returns:
List[dict]: Validated devices from job cache, or [] if cache expired
"""
from core.models import Job
try:
job = Job.objects.get(pk=job_id)
except Job.DoesNotExist:
logger.warning(f"Job {job_id} not found")
return []
if job.status != "completed":
logger.warning(f"Job {job_id} status is {job.status}, not completed")
return []
# Load cached devices from job using shared cache keys
from netbox_librenms_plugin.import_utils import get_validated_device_cache_key
job_data = job.data or {}
device_ids = job_data.get("device_ids", [])
filters = job_data.get("filters", {})
server_key = job_data.get("server_key", "default")
vc_enabled = job_data.get("vc_detection_enabled", False)
use_sysname = job_data.get("use_sysname", True)
strip_domain = job_data.get("strip_domain", False)
# Extract cache metadata for frontend warnings
self._cache_timestamp = job_data.get("cached_at")
self._cache_timeout = job_data.get("cache_timeout", 300)
# Preserve VC detection intent for follow-up actions (confirm/import)
self._vc_detection_enabled = vc_enabled
if not device_ids:
logger.warning(f"Job {job_id} missing device_ids")
return []
# Fetch devices from cache using shared keys
validated_devices = []
for device_id in device_ids:
cache_key = get_validated_device_cache_key(
server_key=server_key,
filters=filters,
device_id=device_id,
vc_enabled=vc_enabled,
use_sysname=use_sysname,
strip_domain=strip_domain,
)
device = cache.get(cache_key)
if device:
validated_devices.append(device)
else:
logger.warning(f"Device {device_id} from job {job_id} not in cache (may have expired)")
if not validated_devices and device_ids:
logger.error(f"Job {job_id} cache expired. Processed {len(device_ids)} devices but none in cache.")
else:
# Mirror the job's naming settings so toggle state matches the cached results
self._use_sysname = use_sysname
self._strip_domain = strip_domain
return validated_devices
def get(self, request, *args, **kwargs): # noqa: D401 - inherited doc
"""Render the import table backed by LibreNMS data."""
self._filter_warning = None
self._filter_form_data = {}
self._libre_filters = {}
self._cache_cleared = False
self._request = request # Store request for connection checks
self._job_results_loaded = False
self._from_cache = False
self._cache_timestamp = None
self._cache_timeout = 300
self._cache_metadata_missing = False
# Resolve naming preferences early so all paths (sync, background job,
# queryset loading) use the same use_sysname/strip_domain values.
# Cascade: user preference → plugin settings → defaults.
try:
settings_obj = LibreNMSSettings.objects.first()
except Exception:
logger.exception(
"Failed to read LibreNMSSettings during LibreNMS import for user %s",
getattr(request, "user", None),
)
settings_obj = None
_use_sysname = get_user_pref(request, "plugins.netbox_librenms_plugin.use_sysname")
_strip_domain = get_user_pref(request, "plugins.netbox_librenms_plugin.strip_domain")
if _use_sysname is None:
_use_sysname = getattr(settings_obj, "use_sysname_default", True) if settings_obj else True
if _strip_domain is None:
_strip_domain = getattr(settings_obj, "strip_domain_default", False) if settings_obj else False
self._use_sysname = _use_sysname
self._strip_domain = _strip_domain
self._settings = settings_obj
# Determine if new filters are being submitted
libre_filter_fields = (
"librenms_location",
"librenms_type",
"librenms_os",
"librenms_hostname",
"librenms_sysname",
"librenms_hardware",
)
filters_present = any(request.GET.get(field) for field in libre_filter_fields)
filters_submitted = request.GET.get("apply_filters") or filters_present
# Check if loading results from completed background job
# Only load job results if NOT submitting new filters
job_id = request.GET.get("job_id")
if job_id and not filters_submitted:
try:
job_id = int(job_id)
logger.info(f"Loading results from job {job_id}")
validated_devices = self._load_job_results(job_id)
if validated_devices:
self._import_data = validated_devices
self._job_results_loaded = True
# Job results are cached data, so mark as from_cache
self._from_cache = True
# Extract filter info from first device's cache or job data
# This allows the filter form to show what was searched
else:
messages.warning(
request,
"Job results have expired. Please re-apply your filters.",
)
except (ValueError, TypeError):
logger.warning(f"Invalid job_id parameter: {request.GET.get('job_id')}")
raw_enable_flag = request.GET.get("enable_vc_detection")
legacy_skip_flag = request.GET.get("skip_vc_detection")
truthy_values = {"1", "true", "on", "True"}
if raw_enable_flag is not None:
self._vc_detection_enabled = raw_enable_flag in truthy_values
elif legacy_skip_flag is not None:
legacy_skip = legacy_skip_flag in truthy_values
self._vc_detection_enabled = not legacy_skip
else:
self._vc_detection_enabled = getattr(self, "_vc_detection_enabled", False)
filter_form = self.filterset_form(request.GET) if self.filterset_form else None
form_valid = False # Track form validity
if filter_form:
form_valid = filter_form.is_valid()
if form_valid:
self._filter_form_data = filter_form.cleaned_data
self._vc_detection_enabled = self._filter_form_data.get("enable_vc_detection")
self._cache_cleared = self._filter_form_data.get("clear_cache")
elif filters_submitted:
non_field_errors = filter_form.non_field_errors()
if non_field_errors:
self._filter_warning = non_field_errors[0]
self._filters_submitted = filters_submitted
# Check if this should be processed as a background job
# Skip if we're loading results from a completed job (job_id in URL)
# IMPORTANT: Only process if form is valid (filter requirement enforced)
device_count = 0
if filters_submitted and form_valid and not self._job_results_loaded and not request.GET.get("job_id"):
# Build filter dict
libre_filters = {}
if location := request.GET.get("librenms_location"):
libre_filters["location"] = location
if device_type := request.GET.get("librenms_type"):
libre_filters["type"] = device_type
if os := request.GET.get("librenms_os"):
libre_filters["os"] = os
if hostname := request.GET.get("librenms_hostname"):
libre_filters["hostname"] = hostname
if sysname := request.GET.get("librenms_sysname"):
libre_filters["sysname"] = sysname
if hardware := request.GET.get("librenms_hardware"):
libre_filters["hardware"] = hardware
from netbox_librenms_plugin.import_utils import (
get_cache_metadata_key,
get_device_count_for_filters,
)
# Check if validated results already exist for this naming-mode
# namespace. The metadata key encodes server, filters, vc_enabled,
# use_sysname and strip_domain, so a naming-preference change
# correctly shows the cache as cold and triggers the background path.
validated_cached = False
if not self._cache_cleared:
try:
metadata_key = get_cache_metadata_key(
server_key=self.librenms_api.server_key,
filters=libre_filters,
vc_enabled=self._vc_detection_enabled,
use_sysname=self._use_sysname,
strip_domain=self._strip_domain,
)
validated_cached = cache.get(metadata_key) is not None
except Exception as e:
logger.debug("Cache check failed; proceeding without cached result: %s", e, exc_info=True)
# Get device count for background job decision
try:
device_count = get_device_count_for_filters(
api=self.librenms_api,
filters=libre_filters,
clear_cache=self._cache_cleared,
show_disabled=bool(self._filter_form_data.get("show_disabled", False)),
)
except Exception as e:
logger.error(f"Error getting device count: {e}")
device_count = 0
# Decide whether to use background job
# Skip background job if validated data is already cached
if not validated_cached and self.should_use_background_job():
# Check if RQ workers are available
if get_workers_for_queue("default") > 0:
from netbox_librenms_plugin.jobs import FilterDevicesJob
# Enqueue background job
job = FilterDevicesJob.enqueue(
user=request.user,
filters=libre_filters,
vc_detection_enabled=self._vc_detection_enabled,
clear_cache=self._cache_cleared,
show_disabled=bool(self._filter_form_data.get("show_disabled")),
exclude_existing=bool(self._filter_form_data.get("exclude_existing")),
server_key=self.librenms_api.server_key,
use_sysname=self._use_sysname,
strip_domain=self._strip_domain,
)
logger.info(
f"Enqueued FilterDevicesJob {job.pk} (UUID: {job.job_id}) for user {request.user} - {device_count} devices"
)
# Return JSON for AJAX polling
# Use background-tasks endpoint to poll Redis queue (where job actually runs)
# IMPORTANT: Use job.job_id (UUID) for background-tasks API, but job.pk for result loading
return JsonResponse(
{
"job_id": str(job.job_id), # UUID for API polling
"job_pk": job.pk, # Integer PK for result loading
"use_polling": True,
"poll_url": f"/api/core/background-tasks/{job.job_id}/",
"device_count": device_count,
}
)
else:
# Fallback to synchronous processing
logger.warning("RQ workers not running, falling back to synchronous processing")
messages.warning(
request,
"Background job system unavailable. Processing may take longer than usual.",
)
queryset = self.get_queryset(request)
table = self.get_table(queryset, request, bulk_actions=True)
filter_warning = self._filter_warning
# Get active cached searches for this server
cached_searches = get_active_cached_searches(self.librenms_api.server_key)
context = {
"model": Device,
"table": table,
"filter_form": filter_form,
"title": self.title,
"filter_warning": filter_warning,
"filters_submitted": filters_submitted,
"show_filter_warning": bool(filter_warning),
"settings": self._settings,
"use_sysname": self._use_sysname,
"strip_domain": self._strip_domain,
"vc_detection_enabled": getattr(self, "_vc_detection_enabled", False),
"cache_cleared": getattr(self, "_cache_cleared", False),
"from_cache": getattr(self, "_from_cache", False),
"cache_timestamp": getattr(self, "_cache_timestamp", None),
"cache_timeout": getattr(self, "_cache_timeout", 300),
"cache_metadata_missing": getattr(self, "_cache_metadata_missing", False),
"cached_searches": cached_searches,
"librenms_server_info": self.get_server_info(),
"can_use_background_jobs": request.user.is_superuser,
"device_count": device_count,
}
return render(request, self.template_name, context)
def get_queryset(self, request): # noqa: D401 - inherited doc
"""Load import data into _import_data and return an empty Device queryset."""
import_data = self._get_import_queryset()
self._import_data = import_data
return Device.objects.none()
def get_table(self, data, request, bulk_actions=True):
"""Return a DeviceImportTable populated with validated import data."""
if not hasattr(self, "_import_data"):
self._import_data = self._get_import_queryset()
data = self._import_data
table = DeviceImportTable(
data,
order_by=request.GET.get("sort"),
)
return table
def _get_import_queryset(self):
# Return job results if already loaded
if getattr(self, "_job_results_loaded", False):
return getattr(self, "_import_data", [])
if not getattr(self, "_filters_submitted", False):
self._libre_filters = {}
return []
if self._filter_warning:
self._libre_filters = {}
return []
data_source = getattr(self, "_filter_form_data", None) or {}
libre_filters = {}
vc_detection_enabled = (
data_source.get("enable_vc_detection")
if "enable_vc_detection" in data_source
else getattr(self, "_vc_detection_enabled", False)
)
clear_cache = (
data_source.get("clear_cache") if "clear_cache" in data_source else getattr(self, "_cache_cleared", False)
)
self._vc_detection_enabled = vc_detection_enabled
self._cache_cleared = clear_cache
if location := data_source.get("librenms_location"):
libre_filters["location"] = location
if device_type := data_source.get("librenms_type"):
libre_filters["type"] = device_type
if os := data_source.get("librenms_os"):
libre_filters["os"] = os
if hostname := data_source.get("librenms_hostname"):
libre_filters["hostname"] = hostname
if sysname := data_source.get("librenms_sysname"):
libre_filters["sysname"] = sysname
if hardware := data_source.get("librenms_hardware"):
libre_filters["hardware"] = hardware
self._libre_filters = libre_filters
# Form validation already ensures at least one filter is present
# No need for redundant check here
# Use shared processing function (same logic as background job)
show_disabled = bool(data_source.get("show_disabled"))
exclude_existing = bool(data_source.get("exclude_existing"))
validated_devices, from_cache = process_device_filters(
api=self.librenms_api,
filters=libre_filters,
vc_detection_enabled=vc_detection_enabled,
clear_cache=clear_cache,
show_disabled=show_disabled,
exclude_existing=exclude_existing,
request=self._request,
return_cache_status=True,
use_sysname=self._use_sysname,
strip_domain=self._strip_domain,
)
self._from_cache = from_cache
# Retrieve cache metadata (timestamp) for countdown display
# This works for both new caches and existing caches
if validated_devices:
from netbox_librenms_plugin.import_utils import get_cache_metadata_key
cache_metadata_key = get_cache_metadata_key(
server_key=self.librenms_api.server_key,
filters=libre_filters,
vc_enabled=vc_detection_enabled,
use_sysname=self._use_sysname,
strip_domain=self._strip_domain,
)
cache_metadata = cache.get(cache_metadata_key)
if cache_metadata:
self._cache_timestamp = cache_metadata.get("cached_at")
self._cache_timeout = cache_metadata.get("cache_timeout", 300)
self._cache_metadata_missing = False
logger.info(
f"Retrieved cache metadata: timestamp={self._cache_timestamp}, "
f"timeout={self._cache_timeout}, from_cache={from_cache}"
)
else:
self._cache_metadata_missing = True
logger.warning(
f"Cache metadata not found for key: {cache_metadata_key}, from_cache={from_cache}. "
f"This may indicate cache key mismatch or metadata expiration."
)
# Mark each device's validation with VC detection flag for downstream views
for device in validated_devices:
if "_validation" in device:
device["_validation"]["_vc_detection_enabled"] = vc_detection_enabled
return validated_devices