first commit
This commit is contained in:
693
netbox_librenms_plugin/views/mixins.py
Normal file
693
netbox_librenms_plugin/views/mixins.py
Normal file
@@ -0,0 +1,693 @@
|
||||
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,
|
||||
}
|
||||
Reference in New Issue
Block a user