Files
netbox-librenms-plugin/netbox_librenms_plugin/import_utils/cache.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

233 lines
8.9 KiB
Python

"""Cache key generation and management for device import operations."""
import hashlib
import json
import logging
from django.core.cache import cache
logger = logging.getLogger(__name__)
def _build_filter_hash(filters: dict) -> str:
"""
Build a stable, collision-free hash from a filter dict.
Removes None values (preserves valid falsy values like 0 and False),
sorts by key, and returns the first 16 hex characters of the SHA-256
digest of the JSON-serialized result.
"""
return hashlib.sha256(
json.dumps({k: v for k, v in filters.items() if v is not None}, sort_keys=True, separators=(",", ":")).encode()
).hexdigest()[:16]
def get_location_choices_cache_key(server_key: str) -> str:
"""Return the cache key for LibreNMS location choices for a given server."""
return f"librenms_locations_choices:{server_key}"
def get_cache_metadata_key(
server_key: str, filters: dict, vc_enabled: bool, use_sysname: bool = True, strip_domain: bool = False
) -> str:
"""
Generate a consistent cache metadata key from filter parameters.
Args:
server_key: LibreNMS server identifier
filters: Filter dictionary
vc_enabled: Whether VC detection is enabled
use_sysname: Whether sysName is preferred over hostname for device naming
strip_domain: Whether domain suffix is stripped from device names
Returns:
str: Consistent cache key for metadata
"""
# Sort filter items to ensure consistent key generation; use "is not None" to preserve
# valid falsy values like 0 and False (filtering only None/missing entries).
# Use JSON serialization for a stable, collision-free hash (avoids issues with
# values containing "=" or "_" that could collide with the key separators).
filter_hash = _build_filter_hash(filters)
return f"librenms_filter_cache_metadata_{server_key}_{filter_hash}_{vc_enabled}_sysname={use_sysname}_strip={strip_domain}"
def get_active_cached_searches(server_key: str) -> list[dict]:
"""
Retrieve all active cached searches for a server and enrich with display-friendly values.
Enriches raw filter IDs with human-readable names by looking up location names
from cached choices and converting type codes to display names.
Args:
server_key: LibreNMS server identifier
Returns:
List of dicts containing cache metadata with enriched display_filters
"""
from datetime import datetime, timezone
cache_index_key = f"librenms_cache_index_{server_key}"
cache_index = cache.get(cache_index_key, [])
active_searches = []
valid_cache_keys = []
# Get location and type choices for enriching display
location_choices = {}
type_choices = {
"": "All Types",
"network": "Network",
"server": "Server",
"storage": "Storage",
"wireless": "Wireless",
"firewall": "Firewall",
"power": "Power",
"appliance": "Appliance",
"printer": "Printer",
"loadbalancer": "Load Balancer",
"other": "Other",
}
# Get cached location choices for enrichment; scoped by server_key so labels
# from different LibreNMS servers don't bleed into each other's filter summaries.
location_cache_key = get_location_choices_cache_key(server_key)
cached_locations = cache.get(location_cache_key)
if cached_locations:
location_choices = dict(cached_locations)
for cache_key in cache_index:
metadata = cache.get(cache_key)
if metadata:
# Cache still exists, calculate time remaining
cache_timeout = metadata.get("cache_timeout", 300)
now = datetime.now(timezone.utc)
try:
cached_at_raw = metadata.get("cached_at")
if isinstance(cached_at_raw, datetime):
cached_at = cached_at_raw
elif cached_at_raw:
cached_at = datetime.fromisoformat(cached_at_raw)
else:
cached_at = datetime.fromtimestamp(0, timezone.utc)
# Normalize naive datetimes (e.g., stored without tzinfo) to UTC
if cached_at.tzinfo is None:
cached_at = cached_at.replace(tzinfo=timezone.utc)
except (ValueError, TypeError):
cached_at = datetime.fromtimestamp(0, timezone.utc)
age_seconds = (now - cached_at).total_seconds()
remaining_seconds = max(0, cache_timeout - age_seconds)
if remaining_seconds > 0:
# Add remaining time and cache key
metadata["remaining_seconds"] = int(remaining_seconds)
metadata["cache_key"] = cache_key
# Store numeric sort key so the final sort is unambiguous
metadata["cached_at_ts"] = cached_at.timestamp()
# Enrich filters with human-readable display values
if "filters" in metadata:
display_filters = metadata["filters"].copy()
# Convert location ID to location name
if "location" in display_filters and display_filters["location"] in location_choices:
display_filters["location"] = location_choices[display_filters["location"]]
# Convert type code to display name
if "type" in display_filters and display_filters["type"] in type_choices:
display_filters["type"] = type_choices[display_filters["type"]]
metadata["display_filters"] = display_filters
else:
# Fallback if filters key missing
metadata["display_filters"] = {}
active_searches.append(metadata)
valid_cache_keys.append(cache_key)
# Clean up index if any keys have expired
if len(valid_cache_keys) < len(cache_index):
cache.set(cache_index_key, valid_cache_keys, timeout=3600)
# Sort by most recent first
active_searches.sort(key=lambda x: x.get("cached_at_ts", 0.0), reverse=True)
return active_searches
def get_validated_device_cache_key(
server_key: str,
filters: dict,
device_id: int | str,
vc_enabled: bool,
use_sysname: bool = True,
strip_domain: bool = False,
) -> str:
"""
Generate a consistent cache key for validated device data.
This ensures both synchronous and background job processing use the same
cache keys, avoiding duplicate validation work and cache entries.
Args:
server_key: LibreNMS server key
filters: Filter dict with location, type, os, hostname, sysname, hardware keys
device_id: LibreNMS device ID
vc_enabled: Whether virtual chassis detection was enabled
use_sysname: Whether sysName is preferred over hostname for device naming
strip_domain: Whether domain suffix is stripped from device names
Returns:
str: Cache key for the validated device
Example:
>>> key = get_validated_device_cache_key('default', {'location': 'NYC'}, 123, True)
>>> key
'validated_device_default_e3b0c44298fc1c14_123_vc'
"""
# Sort filters for a deterministic, cross-process stable hash; None values are excluded
# (consistent with get_cache_metadata_key).
filter_hash = _build_filter_hash(filters)
vc_part = "vc" if vc_enabled else "novc"
return (
f"validated_device_{server_key}_{filter_hash}_{device_id}_{vc_part}_sysname={use_sysname}_strip={strip_domain}"
)
def get_import_device_cache_key(device_id: int | str, server_key: str = "default") -> str:
"""
Generate cache key for raw LibreNMS device data.
This key is used to cache raw device data (without validation metadata)
to avoid redundant API calls when users interact with dropdowns during
the import workflow.
Args:
device_id: LibreNMS device ID
server_key: LibreNMS server identifier for multi-server setups. Defaults to "default" for backward compatibility.
Returns:
str: Cache key for the device data
Example:
>>> get_import_device_cache_key(123, "production")
'import_device_data_production_123'
"""
return f"import_device_data_{server_key}_{device_id}"
def get_import_search_cache_key(server_key: str, api_filters: dict, client_filters: dict) -> str:
"""
Generate a deterministic cache key for a LibreNMS device search result.
The key encodes the server, API-side filters, and client-side filters so
that different filter combinations produce distinct cache entries.
Args:
server_key: Resolved LibreNMS server key (use ``api.server_key``).
api_filters: Filters forwarded to the LibreNMS API.
client_filters: Filters applied client-side after the API response.
Returns:
str: Cache key for the import search result.
"""
return (
f"librenms_devices_import_{server_key}_{_build_filter_hash(api_filters)}_{_build_filter_hash(client_filters)}"
)