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

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

View File

@@ -0,0 +1,787 @@
# forms.py
import logging
from dcim.choices import InterfaceTypeChoices
from dcim.models import Device, DeviceRole, DeviceType, Location, Rack, Site
from django import forms
from django.db.models import Case, IntegerField, Value, When
from django.http import QueryDict
from django.utils.translation import gettext_lazy as _
from netbox.forms import (
NetBoxModelFilterSetForm,
NetBoxModelForm,
NetBoxModelImportForm,
)
from netbox.plugins import get_plugin_config
from utilities.forms.fields import CSVChoiceField, DynamicModelMultipleChoiceField
from virtualization.models import Cluster, VirtualMachine
from .models import InterfaceTypeMapping, LibreNMSSettings
logger = logging.getLogger(__name__)
def _get_librenms_server_choices():
"""
Helper function to get server choices from plugin configuration.
Shared between ServerConfigForm and other forms that need server selection.
"""
choices = []
# Try to get multi-server configuration
servers_config = get_plugin_config("netbox_librenms_plugin", "servers")
if servers_config and isinstance(servers_config, dict):
# Multi-server configuration
for key, config in servers_config.items():
display_name = config.get("display_name", key)
url = config.get("librenms_url", "Unknown URL")
choices.append((key, f"{display_name} ({url})"))
else:
# Legacy single-server configuration
legacy_url = get_plugin_config("netbox_librenms_plugin", "librenms_url")
if legacy_url:
choices.append(("default", f"Default Server ({legacy_url})"))
else:
choices.append(("default", "Default Server"))
return choices
def _get_librenms_poller_group_choices():
"""
Helper function to get poller group choices from LibreNMS API.
Shared between AddToLIbreSNMPV1V2 and AddToLIbreSNMPV3 forms.
"""
from .librenms_api import LibreNMSAPI
choices = [("0", "Default (0)")]
try:
api = LibreNMSAPI()
success, poller_groups = api.get_poller_groups()
if success:
for group in poller_groups or []:
group_id = str(group.get("id", ""))
group_name = group.get("group_name", "")
group_descr = group.get("descr", "")
if group_id:
if group_descr and group_descr != group_name:
label = f"{group_name} - {group_descr} ({group_id})"
else:
label = f"{group_name} ({group_id})"
choices.append((group_id, label))
except Exception:
logger.exception("Failed to fetch LibreNMS poller groups; using default choices")
return choices
class ServerConfigForm(NetBoxModelForm):
"""
Form for selecting the active LibreNMS server from configured servers.
Handles server configuration changes only.
"""
selected_server = forms.ChoiceField(
label="LibreNMS Server",
help_text="Select which LibreNMS server to use for synchronization operations",
)
class Meta:
model = LibreNMSSettings
fields = ["selected_server"]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["selected_server"].choices = _get_librenms_server_choices()
class ImportSettingsForm(NetBoxModelForm):
"""
Form for configuring device import settings including naming patterns
and virtual chassis member naming.
"""
vc_member_name_pattern = forms.CharField(
label="Virtual Chassis Member Naming Pattern",
max_length=100,
required=False,
strip=False, # Preserve leading/trailing whitespace
widget=forms.TextInput(
attrs={
"placeholder": "-M{position}",
}
),
)
use_sysname_default = forms.BooleanField(
label="Use sysName",
required=False,
widget=forms.CheckboxInput(attrs={"class": "form-check-input"}),
help_text="Use SNMP sysName instead of LibreNMS hostname when importing devices",
)
strip_domain_default = forms.BooleanField(
label="Strip domain",
required=False,
widget=forms.CheckboxInput(attrs={"class": "form-check-input"}),
help_text="Remove domain suffix from device names during import",
)
class Meta:
model = LibreNMSSettings
fields = [
"vc_member_name_pattern",
"use_sysname_default",
"strip_domain_default",
]
def clean_vc_member_name_pattern(self):
"""
Validate VC member name pattern for valid placeholders and formatting.
The pattern is used as a suffix appended to the master device name.
Valid placeholders: {position}, {serial}
At least one is required for uniqueness.
"""
pattern = self.cleaned_data.get("vc_member_name_pattern")
if not pattern:
return pattern
# Check for valid placeholder names using regex
import re
valid_placeholders = {"position", "serial"}
found_placeholders = set(re.findall(r"\{(\w+)\}", pattern))
invalid_placeholders = found_placeholders - valid_placeholders
if invalid_placeholders:
invalid_list = ", ".join(f"{{{p}}}" for p in sorted(invalid_placeholders))
error_msg = f"Invalid placeholder(s): {invalid_list}. Valid options are: {{position}}, {{serial}}"
raise forms.ValidationError(error_msg)
# Check required: must have at least one unique identifier
if "{position}" not in pattern and "{serial}" not in pattern:
raise forms.ValidationError(
"The naming pattern must include either {{position}} or {{serial}} "
"placeholder to ensure unique member names."
)
# Test the pattern can be formatted without errors
test_vars = {
"position": 1,
"serial": "ABC123",
}
try:
test_result = pattern.format(**test_vars)
# Check result isn't empty or just whitespace
if not test_result.strip():
raise forms.ValidationError(
"The pattern results in an empty suffix. Please include some text content in the pattern."
)
except KeyError as e:
# This should be caught by check above, but just in case
raise forms.ValidationError(
f"Invalid placeholder in pattern: {e}. Valid options are: {{position}}, {{serial}}"
)
except (ValueError, IndexError) as e:
raise forms.ValidationError(f"Invalid pattern syntax: {str(e)}")
return pattern
# Keep for backward compatibility if needed elsewhere
class LibreNMSSettingsForm(ServerConfigForm):
"""
Deprecated: Use ServerConfigForm or ImportSettingsForm instead.
Kept for backward compatibility.
"""
pass
class InterfaceTypeMappingForm(NetBoxModelForm):
"""
Form for creating and editing interface type mappings between LibreNMS and NetBox.
Allows mapping of LibreNMS interface types and speeds to NetBox interface types.
"""
class Meta:
model = InterfaceTypeMapping
fields = ["librenms_type", "librenms_speed", "netbox_type", "description"]
class InterfaceTypeMappingImportForm(NetBoxModelImportForm):
"""
Form for bulk importing interface type mappings from CSV/JSON/YAML.
Supports importing LibreNMS interface type and speed mappings to NetBox interface types.
"""
netbox_type = CSVChoiceField(
label=_("NetBox Type"),
choices=InterfaceTypeChoices,
help_text=_("NetBox interface type"),
)
class Meta:
model = InterfaceTypeMapping
fields = ["librenms_type", "librenms_speed", "netbox_type", "description"]
class InterfaceTypeMappingFilterForm(NetBoxModelFilterSetForm):
"""
Form for filtering interface type mappings based on LibreNMS and NetBox attributes.
Provides filtering options for LibreNMS type, speed, and NetBox type.
"""
librenms_type = forms.CharField(required=False, label="LibreNMS Type")
librenms_speed = forms.IntegerField(
required=False,
label="LibreNMS Speed (Kbps)",
help_text="Filter by interface speed in Kbps",
)
netbox_type = forms.ChoiceField(
required=False,
label="NetBox Type",
choices=[("", "---------")] + list(InterfaceTypeChoices),
)
description = forms.CharField(
required=False,
label="Description",
help_text="Filter by description (partial match)",
)
model = InterfaceTypeMapping
class AddToLIbreSNMPV1V2(forms.Form):
"""
Form for adding devices to LibreNMS using SNMPv1 or SNMPv2c authentication.
Collects hostname/IP and SNMP community string information.
The SNMP version (v1 or v2c) is selected via a toggle button in the template.
"""
hostname = forms.CharField(
label="Hostname/IP",
max_length=255,
required=True,
)
community = forms.CharField(label="SNMP Community", max_length=255, required=True)
port = forms.IntegerField(
label="SNMP Port",
required=False,
help_text="Leave blank to use default SNMP port (161)",
widget=forms.NumberInput(attrs={"placeholder": "161"}),
)
transport = forms.ChoiceField(
label="Transport",
choices=[
("udp", "UDP"),
("tcp", "TCP"),
("udp6", "UDP6"),
("tcp6", "TCP6"),
],
required=False,
initial="udp",
)
port_association_mode = forms.ChoiceField(
label="Port Association Mode",
choices=[
("ifIndex", "ifIndex"),
("ifName", "ifName"),
("ifDescr", "ifDescr"),
("ifAlias", "ifAlias"),
],
required=False,
initial="ifIndex",
help_text="Method to identify ports",
)
poller_group = forms.ChoiceField(
label="Poller Group",
required=False,
help_text="Poller group for distributed poller setup",
)
force_add = forms.BooleanField(
label="Force Add",
required=False,
initial=False,
help_text="Skip duplicate device and SNMP reachability checks (hostname must still be unique)",
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["poller_group"].choices = _get_librenms_poller_group_choices()
class AddToLIbreSNMPV3(forms.Form):
"""
Form for adding devices to LibreNMS using SNMPv3 authentication.
Provides comprehensive SNMPv3 configuration options including authentication and encryption settings.
"""
hostname = forms.CharField(
label="Hostname/IP",
max_length=255,
required=True,
)
snmp_version = forms.CharField(widget=forms.HiddenInput(), initial="v3")
authlevel = forms.ChoiceField(
label="Auth Level",
choices=[
("noAuthNoPriv", "noAuthNoPriv"),
("authNoPriv", "authNoPriv"),
("authPriv", "authPriv"),
],
required=True,
)
authname = forms.CharField(label="Auth Username", max_length=255, required=True)
authpass = forms.CharField(
label="Auth Password",
max_length=255,
required=True,
widget=forms.PasswordInput(render_value=True),
)
authalgo = forms.ChoiceField(
label="Auth Algorithm",
choices=[
("SHA", "SHA"),
("MD5", "MD5"),
("SHA-224", "SHA-224"),
("SHA-256", "SHA-256"),
("SHA-384", "SHA-384"),
("SHA-512", "SHA-512"),
],
required=True,
)
cryptopass = forms.CharField(
label="Crypto Password",
max_length=255,
required=True,
widget=forms.PasswordInput(render_value=True),
)
cryptoalgo = forms.ChoiceField(
label="Crypto Algorithm",
choices=[("AES", "AES"), ("DES", "DES")],
required=True,
)
port = forms.IntegerField(
label="SNMP Port",
required=False,
help_text="Leave blank to use default SNMP port (161)",
widget=forms.NumberInput(attrs={"placeholder": "161"}),
)
transport = forms.ChoiceField(
label="Transport",
choices=[
("udp", "UDP"),
("tcp", "TCP"),
("udp6", "UDP6"),
("tcp6", "TCP6"),
],
required=False,
initial="udp",
)
port_association_mode = forms.ChoiceField(
label="Port Association Mode",
choices=[
("ifIndex", "ifIndex"),
("ifName", "ifName"),
("ifDescr", "ifDescr"),
("ifAlias", "ifAlias"),
],
required=False,
initial="ifIndex",
help_text="Method to identify ports",
)
poller_group = forms.ChoiceField(
label="Poller Group",
required=False,
help_text="Poller group for distributed poller setup",
)
force_add = forms.BooleanField(
label="Force Add",
required=False,
initial=False,
help_text="Skip duplicate device and SNMP reachability checks (hostname must still be unique)",
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["poller_group"].choices = _get_librenms_poller_group_choices()
class DeviceStatusFilterForm(NetBoxModelFilterSetForm):
"""
Filter form for Device Status view - shows NetBox devices and their LibreNMS status.
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Remove the saved filter field if it exists
if "filter_id" in self.fields:
del self.fields["filter_id"]
site = DynamicModelMultipleChoiceField(queryset=Site.objects.all(), required=False)
location = DynamicModelMultipleChoiceField(queryset=Location.objects.all(), required=False)
rack = DynamicModelMultipleChoiceField(queryset=Rack.objects.all(), required=False)
device_type = DynamicModelMultipleChoiceField(queryset=DeviceType.objects.all(), required=False)
role = DynamicModelMultipleChoiceField(queryset=DeviceRole.objects.all(), required=False)
model = Device
class LibreNMSImportFilterForm(forms.Form):
"""
Filter form for LibreNMS Import view - shows LibreNMS devices for import.
Uses a simple Django form instead of NetBox model forms.
"""
# LibreNMS filters
librenms_location = forms.ChoiceField(
required=False,
label="LibreNMS Location",
choices=[("", "All Locations")], # Default, will be populated in __init__
widget=forms.Select(attrs={"class": "form-select"}),
)
librenms_type = forms.ChoiceField(
required=False,
label="LibreNMS 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"),
],
)
librenms_os = forms.CharField(
required=False,
label="Operating System",
widget=forms.TextInput(attrs={"placeholder": "e.g., ios, linux, junos"}),
)
librenms_hostname = forms.CharField(
required=False,
label="LibreNMS Hostname",
widget=forms.TextInput(attrs={"placeholder": "Partial hostname match"}),
help_text="IP address or FQDN used to add device to LibreNMS",
)
librenms_sysname = forms.CharField(
required=False,
label="LibreNMS System Name",
widget=forms.TextInput(attrs={"placeholder": "Exact or partial sysName match"}),
help_text="SNMP sysName. (exact match only; combine with another filter for partial matching)",
)
librenms_hardware = forms.CharField(
required=False,
label="Hardware",
widget=forms.TextInput(attrs={"placeholder": "e.g., C9300-48P, ASR-920"}),
help_text="LibreNMS hardware model (partial match)",
)
show_disabled = forms.BooleanField(
required=False,
initial=False,
label="Include Disabled Devices",
widget=forms.CheckboxInput(attrs={"class": "form-check-input"}),
)
enable_vc_detection = forms.BooleanField(
required=False,
initial=False,
label="Include Virtual Chassis Detection",
help_text="Run additional stack checks during the search. Will increase processing time.",
widget=forms.CheckboxInput(attrs={"class": "form-check-input"}),
)
clear_cache = forms.BooleanField(
required=False,
initial=False,
label="Clear cache before search",
help_text="Discard the cache and pull fresh data from both LibreNMS and NetBox.",
widget=forms.CheckboxInput(attrs={"class": "form-check-input"}),
)
exclude_existing = forms.BooleanField(
required=False,
initial=False,
label="Exclude Existing Devices",
help_text="Hide devices that already exist in NetBox",
widget=forms.CheckboxInput(attrs={"class": "form-check-input"}),
)
use_background_job = forms.BooleanField(
required=False,
initial=True,
label="Run as background job",
help_text="Recommended: Jobs are logged and can be cancelled.",
widget=forms.CheckboxInput(attrs={"class": "form-check-input"}),
)
def __init__(self, *args, **kwargs):
"""Initialize the form and populate dynamic choices."""
# For bound forms, ensure use_background_job defaults to 'on' if not present
# This handles the case where checkbox is checked by default but not in GET params
# Only apply this default when no filters are applied (initial page load)
if args and isinstance(args[0], (dict, QueryDict)):
# Form is being bound with data (GET/POST dict or QueryDict)
data = args[0].copy() if hasattr(args[0], "copy") else dict(args[0])
# If use_background_job is not in the data, add it with default 'on'
# This makes the checkbox checked by default even on first submission
# Only do this if no filter fields are set (initial page load scenario)
filter_fields = [
"librenms_location",
"librenms_type",
"librenms_os",
"librenms_hostname",
"librenms_sysname",
"librenms_hardware",
]
has_filters = any(data.get(field) for field in filter_fields)
non_option_fields = [
f for f in filter_fields if data.get(f) not in (None, "", []) and str(data.get(f, "")).strip()
]
has_option_only = bool(data) and not bool(non_option_fields) and not has_filters
# Apply default only on initial load (no filters, no job_id, no real submission)
if "use_background_job" not in data and not data.get("job_id") and not has_filters and not has_option_only:
data["use_background_job"] = "on"
args = (data,) + args[1:]
super().__init__(*args, **kwargs)
# Populate LibreNMS location choices dynamically
self._populate_librenms_locations()
def clean(self):
cleaned_data = super().clean()
# Only enforce filter requirement when the user explicitly submits the form
if self.data.get("apply_filters"):
filter_fields = (
"librenms_location",
"librenms_type",
"librenms_os",
"librenms_hostname",
"librenms_sysname",
"librenms_hardware",
)
if not any(cleaned_data.get(field) for field in filter_fields):
raise forms.ValidationError("Please select at least one LibreNMS filter before applying the search.")
return cleaned_data
def _populate_librenms_locations(self):
"""Fetch and populate LibreNMS locations in the dropdown."""
from django.core.cache import cache
from netbox_librenms_plugin.import_utils.cache import get_location_choices_cache_key
from netbox_librenms_plugin.librenms_api import LibreNMSAPI
try:
# Determine server_key cheaply from settings to check cache before instantiating the API
try:
from netbox_librenms_plugin.models import LibreNMSSettings
_settings = LibreNMSSettings.objects.first()
_server_key = (_settings.selected_server if _settings else None) or "default"
except Exception:
_server_key = "default"
cache_key = get_location_choices_cache_key(_server_key)
cached_choices = cache.get(cache_key)
if cached_choices is not None:
self.fields["librenms_location"].choices = cached_choices
return
# Cache miss — instantiate the API client and fetch
api = LibreNMSAPI()
# Recompute cache_key with the resolved server_key in case it differs from settings
cache_key = get_location_choices_cache_key(api.server_key)
# Second cache check: the resolved server_key may differ from the settings key
cached_choices = cache.get(cache_key)
if cached_choices is not None:
self.fields["librenms_location"].choices = cached_choices
return
# Fetch locations from LibreNMS
success, locations = api.get_locations()
if success and locations:
# Build choices list: (id, name)
choices = [("", "All Locations")]
for loc in locations:
loc_id = str(loc.get("id", ""))
loc_name = loc.get("location", f"Location {loc_id}")
choices.append((loc_id, loc_name))
# Sort by name
choices[1:] = sorted(choices[1:], key=lambda x: x[1])
self.fields["librenms_location"].choices = choices
# Cache using configured timeout (default 300s)
cache.set(cache_key, choices, timeout=api.cache_timeout)
logger.info(f"Loaded {len(choices) - 1} LibreNMS locations")
else:
logger.warning(f"Failed to load LibreNMS locations: {locations}")
except Exception as e:
logger.exception(f"Error loading LibreNMS locations: {e}")
# Keep default choices on error
class VirtualMachineStatusFilterForm(NetBoxModelFilterSetForm):
"""
Form for filtering virtual machine status information in NetBox.
"""
def __init__(self, *args, **kwargs):
"""Initialize the form and remove the filter_id field if it exists."""
super().__init__(*args, **kwargs)
# Remove the saved filter field if it exists
if "filter_id" in self.fields:
del self.fields["filter_id"]
virtualmachine = DynamicModelMultipleChoiceField(queryset=VirtualMachine.objects.all(), required=False)
site = DynamicModelMultipleChoiceField(queryset=Site.objects.all(), required=False)
cluster = DynamicModelMultipleChoiceField(queryset=Cluster.objects.all(), required=False)
model = VirtualMachine
class DeviceImportConfigForm(forms.Form):
"""
Form for configuring import of LibreNMS devices with missing prerequisites.
Allows user to manually map LibreNMS device data to NetBox objects.
"""
device_id = forms.IntegerField(widget=forms.HiddenInput(), required=True)
hostname = forms.CharField(disabled=True, required=False, label="Device Hostname")
hardware = forms.CharField(disabled=True, required=False, label="Hardware")
librenms_location = forms.CharField(disabled=True, required=False, label="LibreNMS Location")
# Required mappings
site = forms.ModelChoiceField(
queryset=Site.objects.all(),
required=True,
label="NetBox Site",
help_text="Select the NetBox site for this device",
widget=forms.Select(attrs={"class": "form-select"}),
)
device_type = forms.ModelChoiceField(
queryset=DeviceType.objects.all(),
required=True,
label="Device Type",
help_text="Select the NetBox device type",
widget=forms.Select(attrs={"class": "form-select"}),
)
device_role = forms.ModelChoiceField(
queryset=DeviceRole.objects.all(),
required=True,
label="Device Role",
help_text="Select the device role",
widget=forms.Select(attrs={"class": "form-select"}),
)
# Optional mappings
platform = forms.ModelChoiceField(
queryset=None,
required=False,
label="Platform",
help_text="Select platform (optional)",
widget=forms.Select(attrs={"class": "form-select"}),
)
# Sync options
sync_interfaces = forms.BooleanField(
initial=True,
required=False,
label="Sync Interfaces",
help_text="Automatically sync interfaces from LibreNMS after import",
)
sync_cables = forms.BooleanField(
initial=True,
required=False,
label="Sync Cables",
help_text="Automatically sync cable connections from LibreNMS after import",
)
sync_ips = forms.BooleanField(
initial=True,
required=False,
label="Sync IP Addresses",
help_text="Automatically sync IP addresses from LibreNMS after import",
)
def __init__(self, *args, **kwargs):
"""
Initialize form with LibreNMS device data and validation results.
Accepts additional kwargs:
- libre_device: LibreNMS device dictionary
- validation: Validation result dictionary
- suggested_site: Pre-selected site
- suggested_device_type: Pre-selected device type
- suggested_role: Pre-selected device role
"""
# Extract custom kwargs
libre_device = kwargs.pop("libre_device", {})
validation = kwargs.pop("validation", {})
suggested_site = kwargs.pop("suggested_site", None)
suggested_device_type = kwargs.pop("suggested_device_type", None)
suggested_role = kwargs.pop("suggested_role", None)
super().__init__(*args, **kwargs)
# Import Platform here to avoid circular imports
from dcim.models import Platform
self.fields["platform"].queryset = Platform.objects.all()
# Set initial values from LibreNMS device
if libre_device:
self.fields["device_id"].initial = libre_device.get("device_id")
self.fields["hostname"].initial = libre_device.get("hostname", "")
self.fields["hardware"].initial = libre_device.get("hardware", "")
self.fields["librenms_location"].initial = libre_device.get("location", "")
# Set suggested values from validation
if suggested_site:
self.fields["site"].initial = suggested_site
elif validation and validation.get("site", {}).get("site"):
self.fields["site"].initial = validation["site"]["site"]
if suggested_device_type:
self.fields["device_type"].initial = suggested_device_type
elif validation and validation.get("device_type", {}).get("device_type"):
self.fields["device_type"].initial = validation["device_type"]["device_type"]
if suggested_role:
self.fields["device_role"].initial = suggested_role
elif validation and validation.get("device_role", {}).get("role"):
self.fields["device_role"].initial = validation["device_role"]["role"]
if validation and validation.get("platform", {}).get("platform"):
self.fields["platform"].initial = validation["platform"]["platform"]
# Filter device types by suggestions if available
if validation and validation.get("device_type", {}).get("suggestions"):
suggestions = validation["device_type"]["suggestions"]
if suggestions:
# Annotate with suggested_order so suggested types sort first
suggested_ids = [s["device_type"].id for s in suggestions]
priority = Case(
*[When(id=pk, then=Value(i)) for i, pk in enumerate(suggested_ids)],
default=Value(len(suggested_ids)),
output_field=IntegerField(),
)
self.fields["device_type"].queryset = DeviceType.objects.annotate(suggested_order=priority).order_by(
"suggested_order", "manufacturer__name", "model"
)