from django.contrib import messages from django.contrib.auth.mixins import PermissionRequiredMixin from django.http import HttpResponse from django.shortcuts import redirect from django.utils.http import url_has_allowed_host_and_scheme from utilities.permissions import get_permission_for_model from netbox_librenms_plugin.constants import PERM_CHANGE_PLUGIN, PERM_VIEW_PLUGIN from netbox_librenms_plugin.librenms_api import LibreNMSAPI def _get_safe_redirect_url(request): """ Return a validated redirect URL from the HTTP Referer header. Validates the Referer against allowed hosts and schemes to prevent open-redirect attacks. Falls back to the current request path or "/". """ referrer = request.META.get("HTTP_REFERER") if referrer and url_has_allowed_host_and_scheme( referrer, allowed_hosts={request.get_host()}, require_https=request.is_secure(), ): return referrer return getattr(request, "path", "/") class LibreNMSPermissionMixin(PermissionRequiredMixin): """ Mixin for views requiring LibreNMS plugin permissions. All plugin views require 'view_librenmssettings' to access the page. Write actions require 'change_librenmssettings' plus any relevant NetBox object permissions. """ permission_required = PERM_VIEW_PLUGIN def has_write_permission(self): """Check if user can perform write actions.""" return self.request.user.has_perm(PERM_CHANGE_PLUGIN) def require_write_permission(self, error_message=None): """ Check write permission and return error response if denied. Handles both HTMX and regular requests appropriately: - HTMX: Returns HX-Redirect to referrer with toast message - Regular: Returns redirect to referrer with flash message Returns: None if permitted, or appropriate response if denied """ if not self.has_write_permission(): msg = error_message or "You do not have permission to perform this action." messages.error(self.request, msg) referrer = _get_safe_redirect_url(self.request) # Check if this is an HTMX request if self.request.headers.get("HX-Request"): return HttpResponse("", headers={"HX-Redirect": referrer}) return redirect(referrer) return None def require_write_permission_json(self, error_message=None): """ Check write permission and return JSON error response if denied. Use this method for AJAX/HTMX endpoints that return JsonResponse. Does not set flash messages since JSON clients handle errors differently. Returns: None if permitted, or JsonResponse with 403 status if denied """ from django.http import JsonResponse if not self.has_write_permission(): msg = error_message or "You do not have permission to perform this action." return JsonResponse({"error": msg}, status=403) return None class NetBoxObjectPermissionMixin: """ Mixin for views requiring specific NetBox object permissions. Define required_object_permissions as a dict mapping HTTP methods to lists of (action, model) tuples. Example: required_object_permissions = { 'POST': [ ('add', Interface), ('change', Interface), ], } """ required_object_permissions = {} def check_object_permissions(self, method): """ Check all required object permissions for the given HTTP method. Args: method: HTTP method (GET, POST, etc.) Returns: tuple: (has_all: bool, missing: list[str]) """ requirements = self.required_object_permissions.get(method, []) missing = [] for action, model in requirements: perm = get_permission_for_model(model, action) if not self.request.user.has_perm(perm): missing.append(perm) return (len(missing) == 0, missing) def require_object_permissions(self, method): """ Require all object permissions for the method, returning error response if denied. Handles both HTMX and regular requests appropriately: - HTMX: Returns HX-Redirect to referrer with flash message - Regular: Returns redirect to referrer with flash message Returns: None if permitted, or appropriate response if denied """ has_perms, missing = self.check_object_permissions(method) if not has_perms: missing_str = ", ".join(missing) msg = f"Missing permissions: {missing_str}" messages.error(self.request, msg) referrer = _get_safe_redirect_url(self.request) # Check if this is an HTMX request if self.request.headers.get("HX-Request"): return HttpResponse("", headers={"HX-Redirect": referrer}) return redirect(referrer) return None def require_object_permissions_json(self, method): """ Require all object permissions for the method, returning JSON error if denied. Use this method for AJAX/HTMX endpoints that return JsonResponse. Does not set flash messages since JSON clients handle errors differently. Returns: None if permitted, or JsonResponse with 403 status if denied """ from django.http import JsonResponse has_perms, missing = self.check_object_permissions(method) if not has_perms: missing_str = ", ".join(missing) return JsonResponse({"error": f"Missing permissions: {missing_str}"}, status=403) return None def require_all_permissions(self, method="POST"): """ Check both plugin write and NetBox object permissions. Combines require_write_permission() and require_object_permissions() into a single call. Handles HTMX and regular requests. Returns: None if permitted, or appropriate error response if denied """ if error := self.require_write_permission(): return error return self.require_object_permissions(method) def require_all_permissions_json(self, method="POST"): """ Check both plugin write and NetBox object permissions, returning JSON errors. Combines require_write_permission_json() and require_object_permissions_json() into a single call for JSON/AJAX endpoints. Returns: None if permitted, or JsonResponse with 403 status if denied """ if error := self.require_write_permission_json(): return error return self.require_object_permissions_json(method) class LibreNMSAPIMixin: """ A mixin class that provides access to the LibreNMS API. This mixin initializes a LibreNMSAPI instance and provides a property to access it. It's designed to be used with other view classes that need to interact with the LibreNMS API. Attributes: _librenms_api (LibreNMSAPI): An instance of the LibreNMSAPI class. Properties: librenms_api (LibreNMSAPI): A property that returns the LibreNMSAPI instance, creating it if it doesn't exist. """ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._librenms_api = None @property def librenms_api(self): """ Get or create an instance of LibreNMSAPI. This property ensures that only one instance of LibreNMSAPI is created and reused for subsequent calls. The API instance will use the currently selected server from settings. Returns: LibreNMSAPI: An instance of the LibreNMSAPI class. """ if self._librenms_api is None: # The LibreNMSAPI will automatically use the selected server self._librenms_api = LibreNMSAPI() return self._librenms_api def get_server_info(self): """ Get information about the currently active LibreNMS server. Returns: dict: Server information including display name and URL """ try: # Get the current server key server_key = self.librenms_api.server_key # Try to get multi-server configuration from netbox.plugins import get_plugin_config servers_config = get_plugin_config("netbox_librenms_plugin", "servers") if servers_config and isinstance(servers_config, dict) and server_key in servers_config: # Multi-server configuration config = servers_config[server_key] return { "display_name": config.get("display_name", server_key), "url": config["librenms_url"], "is_legacy": False, "server_key": server_key, } else: # Legacy configuration legacy_url = get_plugin_config("netbox_librenms_plugin", "librenms_url") return { "display_name": "Default Server", "url": legacy_url or "Not configured", "is_legacy": True, "server_key": "default", } except (KeyError, AttributeError, ImportError): return { "display_name": "Unknown Server", "url": "Configuration error", "is_legacy": True, "server_key": "unknown", } def get_context_data(self, **kwargs): """Add server info to context for all views using this mixin.""" try: context = super().get_context_data(**kwargs) except AttributeError: context = kwargs context["librenms_server_info"] = self.get_server_info() return context class CacheMixin: """ A mixin class that provides caching functionality. """ def get_cache_key(self, obj, data_type="ports", server_key=None): """ Get the cache key for the object. Args: obj: The object to cache data for data_type: Type of data being cached ('ports', 'links', 'inventory', etc.) server_key: Optional LibreNMS server key for namespacing per-server data """ model_name = obj._meta.model_name base = f"librenms_{data_type}_{model_name}_{obj.pk}" if server_key: return f"{base}_{server_key}" return base def get_last_fetched_key(self, obj, data_type="ports", server_key=None): """ Get the cache key for the last fetched time of the object. """ model_name = obj._meta.model_name base = f"librenms_{data_type}_last_fetched_{model_name}_{obj.pk}" if server_key: return f"{base}_{server_key}" return base def get_vlan_overrides_key(self, obj, server_key=None): """ Get the cache key for user VLAN group override selections. Stores a {vid_str: group_id_str} map so that "apply to all" VLAN group choices persist across table pages. Including server_key scopes overrides per-server to avoid leakage when multiple servers are configured. """ model_name = obj._meta.model_name if server_key: return f"librenms_vlan_group_overrides_{model_name}_{obj.pk}_{server_key}" return f"librenms_vlan_group_overrides_{model_name}_{obj.pk}" class VlanAssignmentMixin: """ Mixin providing VLAN assignment utilities for views. Provides methods for: - Getting relevant VLAN groups for a device based on scope hierarchy - Building lookup maps for VLAN matching - Selecting the most specific VLAN group based on device context - Finding VLANs by VID within a specific group - Updating interface VLAN assignments """ def get_vlan_groups_for_device(self, device): """ Get all VLAN groups relevant to this device. Searches for VLAN groups scoped to: - Site: The device's assigned site - Location: The device's location and all parent locations - Region: The device's site's region and all parent regions - Site Group: The device's site's group and all parent site groups - Rack: The device's rack - Global: VLAN groups with no scope Returns: List of VLANGroup objects, deduplicated and sorted by name """ from dcim.models import Location, Rack, Region, Site, SiteGroup from ipam.models import VLANGroup groups = set() # Site-scoped VLAN groups if hasattr(device, "site") and device.site: site_groups = self._get_vlan_groups_for_scope(Site, [device.site]) groups.update(site_groups) # Region-scoped VLAN groups (site's region and ancestors) if device.site.region: region_ancestors = self._get_ancestors(device.site.region) region_groups = self._get_vlan_groups_for_scope(Region, region_ancestors) groups.update(region_groups) # Site Group-scoped VLAN groups (site's group and ancestors) if device.site.group: site_group_ancestors = self._get_ancestors(device.site.group) site_group_groups = self._get_vlan_groups_for_scope(SiteGroup, site_group_ancestors) groups.update(site_group_groups) # Location-scoped VLAN groups (device's location and ancestors) if hasattr(device, "location") and device.location: location_ancestors = self._get_ancestors(device.location) location_groups = self._get_vlan_groups_for_scope(Location, location_ancestors) groups.update(location_groups) # Rack-scoped VLAN groups if hasattr(device, "rack") and device.rack: rack_groups = self._get_vlan_groups_for_scope(Rack, [device.rack]) groups.update(rack_groups) # Global VLAN groups (no scope) global_groups = VLANGroup.objects.filter(scope_type__isnull=True) groups.update(global_groups) # Return sorted by name for consistent display return sorted(groups, key=lambda g: g.name.lower()) def _build_vlan_lookup_maps(self, vlan_groups): """ Build lookup dictionaries for VLAN matching. Returns a dict with: - vid_to_groups: {vid: [vlan_group, ...]} - VID to groups containing that VID - vid_group_to_vlan: {(vid, group_id): vlan} - unique per group lookup - vid_to_vlans: {vid: [vlan, ...]} - all VLANs with that VID - vid_name_to_vlan: {(vid, name): vlan} - VID + name lookup """ from ipam.models import VLAN vid_to_groups = {} vid_group_to_vlan = {} vid_to_vlans = {} vid_name_to_vlan = {} # Get all VLANs from relevant groups and global VLANs group_pks = [g.pk for g in vlan_groups] vlans = VLAN.objects.filter(group__pk__in=group_pks).select_related("group") # Also get global VLANs (no group) global_vlans = VLAN.objects.filter(group__isnull=True) for vlan in list(vlans) + list(global_vlans): vid = vlan.vid group = vlan.group group_id = group.pk if group else None name = vlan.name # Build VID to groups lookup for ambiguity detection (group VLANs only) if group: if vid not in vid_to_groups: vid_to_groups[vid] = [] if group not in vid_to_groups[vid]: vid_to_groups[vid].append(group) # Build (vid, group_id) to vlan lookup vid_group_to_vlan[(vid, group_id)] = vlan # Build VID to all VLANs list (for dropdown options) if vid not in vid_to_vlans: vid_to_vlans[vid] = [] vid_to_vlans[vid].append(vlan) # Build (vid, name) to vlan lookup vid_name_to_vlan[(vid, name)] = vlan return { "vid_to_groups": vid_to_groups, "vid_group_to_vlan": vid_group_to_vlan, "vid_to_vlans": vid_to_vlans, "vid_name_to_vlan": vid_name_to_vlan, } def _select_most_specific_group(self, groups, device): """ Select the most specific VLAN group based on device context. Priority order (most specific to least specific): 1. Rack-scoped (device's rack) 2. Location-scoped (device's location, closer ancestors win) 3. Site-scoped (device's site) 4. Site Group-scoped (device's site's group, closer ancestors win) 5. Region-scoped (device's site's region, closer ancestors win) 6. Global (no scope) Args: groups: List of VLANGroup objects that all contain the same VID device: NetBox Device object Returns: VLANGroup or None if no clear winner (e.g., multiple groups at same priority level) """ from dcim.models import Location, Rack, Region, Site, SiteGroup from django.contrib.contenttypes.models import ContentType if not device or not groups: return None # Build scope priority lookup for this device # Lower number = higher priority (more specific) scope_priority = {} priority = 0 # Priority 1: Rack (most specific) if hasattr(device, "rack") and device.rack: rack_ct = ContentType.objects.get_for_model(Rack) scope_priority[(rack_ct.pk, device.rack.pk)] = priority priority += 1 # Priority 2: Location hierarchy (device's location first, then ancestors) if hasattr(device, "location") and device.location: location_ct = ContentType.objects.get_for_model(Location) for loc in self._get_ancestors(device.location): scope_priority[(location_ct.pk, loc.pk)] = priority priority += 1 # Priority 3: Site if hasattr(device, "site") and device.site: site_ct = ContentType.objects.get_for_model(Site) scope_priority[(site_ct.pk, device.site.pk)] = priority priority += 1 # Priority 4: Site Group hierarchy if device.site.group: site_group_ct = ContentType.objects.get_for_model(SiteGroup) for sg in self._get_ancestors(device.site.group): scope_priority[(site_group_ct.pk, sg.pk)] = priority priority += 1 # Priority 5: Region hierarchy if device.site.region: region_ct = ContentType.objects.get_for_model(Region) for reg in self._get_ancestors(device.site.region): scope_priority[(region_ct.pk, reg.pk)] = priority priority += 1 # Priority 6: Global (no scope) - lowest priority global_priority = priority # Find the group with the highest priority (lowest number) best_group = None best_priority = float("inf") same_priority_count = 0 for group in groups: if group.scope_type is None: # Global scope group_priority = global_priority else: scope_key = (group.scope_type.pk, group.scope_id) group_priority = scope_priority.get(scope_key, float("inf")) if group_priority < best_priority: best_priority = group_priority best_group = group same_priority_count = 1 elif group_priority == best_priority: same_priority_count += 1 # Only return a group if there's a single winner at the best priority level if same_priority_count == 1 and best_group is not None: return best_group return None def _get_ancestors(self, obj): """ Get all ancestors of a hierarchical object (location, region, site group). Returns list including the object itself and all parents up to root. """ ancestors = [] current = obj while current is not None: ancestors.append(current) current = getattr(current, "parent", None) return ancestors def _get_vlan_groups_for_scope(self, model_class, objects): """ Get VLAN groups scoped to any of the given objects. Args: model_class: The Django model class (Site, Location, Region, etc.) objects: List of model instances to check Returns: QuerySet of VLANGroup objects """ from django.contrib.contenttypes.models import ContentType from ipam.models import VLANGroup if not objects: return VLANGroup.objects.none() content_type = ContentType.objects.get_for_model(model_class) object_ids = [obj.pk for obj in objects if obj is not None and obj.pk is not None] if not object_ids: return VLANGroup.objects.none() return VLANGroup.objects.filter(scope_type=content_type, scope_id__in=object_ids) def _find_vlan_in_group(self, vid, vlan_group_id, lookup_maps): """ Find a VLAN by VID, preferring the specified group. Args: vid: VLAN ID (integer) vlan_group_id: Optional VLAN group ID to prefer lookup_maps: Dict from _build_vlan_lookup_maps() Returns: VLAN object or None """ vid_group_to_vlan = lookup_maps.get("vid_group_to_vlan", {}) vid_to_vlans = lookup_maps.get("vid_to_vlans", {}) # Try specific group first if vlan_group_id: try: vlan = vid_group_to_vlan.get((vid, int(vlan_group_id))) if vlan: return vlan except (ValueError, TypeError): pass # Try global (no group) vlan = vid_group_to_vlan.get((vid, None)) if vlan: return vlan # Fallback: first matching VLAN vlans = vid_to_vlans.get(vid, []) return vlans[0] if vlans else None def _update_interface_vlan_assignment(self, interface, vlan_data, vlan_group_map, lookup_maps): """ Update interface VLAN assignments in NetBox (mode, untagged_vlan, tagged_vlans). Args: interface: NetBox Interface or VMInterface object vlan_data: Dict with 'untagged_vlan' (int or None) and 'tagged_vlans' (list of ints) vlan_group_map: Dict mapping VID (str) to VLAN group ID for per-VLAN group lookups. Can also be a single group ID string for backward compat. lookup_maps: Dict from _build_vlan_lookup_maps() Returns: Dict with sync results: - mode_set: str or None - untagged_set: VLAN object or None - tagged_set: list of VLAN objects - missing_vlans: list of VIDs not found in NetBox """ # Support both dict (per-VLAN) and string/int/None (single group) for backward compat if not isinstance(vlan_group_map, dict): single_group_id = vlan_group_map vlan_group_map = None else: single_group_id = None untagged_vid = vlan_data.get("untagged_vlan") tagged_vids = vlan_data.get("tagged_vlans", []) missing_vlans = [] def _get_group_id_for_vid(vid): """Resolve the VLAN group ID for a specific VID.""" if vlan_group_map is not None: return vlan_group_map.get(str(vid), "") return single_group_id or "" # Determine mode if tagged_vids: interface.mode = "tagged" elif untagged_vid: interface.mode = "access" else: # No VLANs - clear mode interface.mode = "" # Set untagged VLAN untagged_set = None if untagged_vid: vlan = self._find_vlan_in_group(untagged_vid, _get_group_id_for_vid(untagged_vid), lookup_maps) if vlan: interface.untagged_vlan = vlan untagged_set = vlan else: missing_vlans.append(untagged_vid) interface.untagged_vlan = None else: interface.untagged_vlan = None # Save mode + untagged_vlan before M2M operations. # tagged_vlans.set() triggers a DB refresh that wipes unsaved # in-memory attributes, so we must persist first. interface.save() # Set tagged VLANs (M2M - requires the instance to be saved first) tagged_set = [] if tagged_vids: for vid in tagged_vids: vlan = self._find_vlan_in_group(vid, _get_group_id_for_vid(vid), lookup_maps) if vlan: tagged_set.append(vlan) else: missing_vlans.append(vid) interface.tagged_vlans.set(tagged_set) else: interface.tagged_vlans.clear() return { "mode_set": interface.mode, "untagged_set": untagged_set, "tagged_set": tagged_set, "missing_vlans": missing_vlans, }