netbox/netbox/dcim/utils.py

328 lines
13 KiB
Python

from collections import defaultdict
from django.apps import apps
from django.contrib.contenttypes.models import ContentType
from django.db import router, transaction
from django.utils.translation import gettext as _
from dcim.constants import MODULE_TOKEN
def inherit_module_token(position, parent_positions):
"""
Resolve a single {module} token in a bay position by inheriting from the position
one level deeper in a module bay hierarchy. Returns position unchanged unless
parent_positions is non-empty and position contains {module}, in which case the
token is substituted with parent_positions[-1].
Used by resolve_position_chain(), the single inheritance implementation shared by
get_module_bay_positions() and the module move planner.
"""
if parent_positions and MODULE_TOKEN in position:
return position.replace(MODULE_TOKEN, parent_positions[-1])
return position
def get_module_bay_raw_positions(module_bay):
"""
Given a module bay, traverse up the module hierarchy and return the stored
(unresolved) bay position strings from root to leaf.
Raises ValueError if the module bay hierarchy contains a cycle.
"""
positions = []
visited = set()
while module_bay:
if module_bay.pk in visited:
raise ValueError(_("Module bay hierarchy contains a cycle."))
visited.add(module_bay.pk)
positions.append(module_bay.position or '')
module_bay = module_bay.module.module_bay if module_bay.module else None
positions.reverse()
return positions
def resolve_position_chain(raw_positions):
"""
Apply leaf-to-root {module} token inheritance over a root-to-leaf list of raw bay
positions: each position inherits from the resolved position one level deeper, and
the leaf's own token is never resolved. Shared by get_module_bay_positions() and
the module move planner so a planned chain always equals what a fresh walk
computes once the planned positions are stored.
"""
resolved = []
for position in reversed(raw_positions):
resolved.append(inherit_module_token(position, resolved))
resolved.reverse()
return resolved
def get_module_bay_positions(module_bay):
"""
Given a module bay, traverse up the module hierarchy and return a list of bay
position strings from root to leaf, resolving any {module} tokens in each
position using the parent position (position inheritance).
Raises ValueError if the module bay hierarchy contains a cycle.
"""
return resolve_position_chain(get_module_bay_raw_positions(module_bay))
def resolve_module_placeholder(value, positions):
"""
Resolve {module} placeholder tokens in a string using the given
list of module bay positions (ordered root to leaf).
A single {module} token resolves to the leaf (immediate parent) bay's position.
Multiple tokens must match the tree depth and resolve level-by-level.
Returns the resolved string.
Raises ValueError if token count is greater than 1 and doesn't match tree depth.
"""
if MODULE_TOKEN not in value:
return value
token_count = value.count(MODULE_TOKEN)
if token_count == 1:
return value.replace(MODULE_TOKEN, positions[-1])
if token_count == len(positions):
for pos in positions:
value = value.replace(MODULE_TOKEN, pos, 1)
return value
raise ValueError(
_("Cannot install module with placeholder values in a module bay tree "
"{level} levels deep but {tokens} placeholders given.").format(
level=len(positions), tokens=token_count
)
)
def compile_path_node(ct_id, object_id):
return f'{ct_id}:{object_id}'
def decompile_path_node(repr):
ct_id, object_id = repr.split(':')
return int(ct_id), int(object_id)
def object_to_path_node(obj):
"""
Return a representation of an object suitable for inclusion in a CablePath path. Node representation is in the
form <ContentType ID>:<Object ID>.
"""
ct = ContentType.objects.get_for_model(obj)
return compile_path_node(ct.pk, obj.pk)
def path_node_to_object(repr):
"""
Given the string representation of a path node, return the corresponding instance. If the object no longer
exists, return None.
"""
ct_id, object_id = decompile_path_node(repr)
ct = ContentType.objects.get_for_id(ct_id)
return ct.model_class().objects.filter(pk=object_id).first()
def create_cablepaths(objects):
"""
Create CablePaths for all paths originating from the specified set of nodes.
:param objects: Iterable of cabled objects (e.g. Interfaces)
"""
from dcim.models import CablePath, Interface
# Expand any channelized interface into its channel subinterfaces. A channelized parent originates no path of its
# own; instead, each channel subinterface traces independently from the single connector position it occupies.
# Plain (non-channelized) origins pass through unchanged, keeping this expansion re-entrant so that
# rebuild_paths() -> create_cablepaths(cp.origins) does not re-expand the channel subinterfaces it already holds.
expanded = []
for obj in objects:
if isinstance(obj, Interface) and obj.channels:
expanded.extend(obj.child_interfaces.filter(channel_id__isnull=False, cable__isnull=False))
else:
expanded.append(obj)
# Arrange objects by cable connector. All objects with a null connector are grouped together. Channel
# subinterfaces must each originate their own path, as sharing a connector would otherwise collapse a group of
# siblings into a single malformed path.
origins = defaultdict(list)
for obj in expanded:
if isinstance(obj, Interface) and obj.channel_id:
if cp := CablePath.from_origin([obj]):
cp.save()
else:
origins[obj.cable_connector].append(obj)
for connector, objects in origins.items():
if cp := CablePath.from_origin(objects):
cp.save()
def rebuild_paths(terminations):
"""
Rebuild all CablePaths which traverse the specified nodes.
"""
from dcim.models import CablePath
for obj in terminations:
cable_paths = CablePath.objects.filter(_nodes__contains=obj)
with transaction.atomic(using=router.db_for_write(CablePath)):
for cp in cable_paths:
cp.delete()
create_cablepaths(cp.origins)
def rebuild_cable_paths(cable):
"""
Delete and rebuild every CablePath traversing the given Cable, tracing freshly from the Cable's current
terminations in both directions. Used when the channelization of a terminated interface changes (e.g. a channel
subinterface is added, moved, or removed) without the Cable itself being modified.
"""
from dcim.choices import CableEndChoices
from dcim.models import CablePath, CableTermination, PathEndpoint
with transaction.atomic(using=router.db_for_write(CablePath)):
# Delete existing paths individually so each clears its `_path` back-reference on the originating endpoints.
for cp in CablePath.objects.filter(_nodes__contains=cable):
cp.delete()
a_terminations, b_terminations = [], []
for ct in CableTermination.objects.filter(cable=cable):
if ct.cable_end == CableEndChoices.SIDE_A:
a_terminations.append(ct.termination)
else:
b_terminations.append(ct.termination)
for nodes in (a_terminations, b_terminations):
if not nodes:
continue
if isinstance(nodes[0], PathEndpoint):
create_cablepaths(nodes)
else:
rebuild_paths(nodes)
def update_interface_parents(device, interface_templates, module=None):
"""
Used for device and module instantiation. Iterates all InterfaceTemplates with a parent assigned and applies it to
the actual interfaces. Must run after all interfaces have been instantiated (so that every parent interface exists)
and before update_interface_bridges() (so that channel subinterfaces validate against a populated parent).
"""
Interface = apps.get_model('dcim', 'Interface')
for interface_template in interface_templates.exclude(parent=None):
interface = Interface.objects.get(device=device, name=interface_template.resolve_name(module=module))
interface.parent = Interface.objects.get(
device=device,
name=interface_template.parent.resolve_name(module=module)
)
interface.full_clean()
interface.save()
def update_interface_bridges(device, interface_templates, module=None):
"""
Used for device and module instantiation. Iterates all InterfaceTemplates with a bridge assigned
and applies it to the actual interfaces.
"""
Interface = apps.get_model('dcim', 'Interface')
for interface_template in interface_templates.exclude(bridge=None):
interface = Interface.objects.get(
device=device,
name=interface_template.resolve_name(module=module, device=device)
)
if interface_template.bridge:
interface.bridge = Interface.objects.get(
device=device,
name=interface_template.bridge.resolve_name(module=module, device=device)
)
interface.full_clean()
interface.save()
def create_port_mappings(device, device_or_module_type, module=None):
"""
Replicate all front/rear port mappings from a DeviceType or ModuleType to the given device.
"""
from dcim.models import FrontPort, PortMapping, RearPort
templates = device_or_module_type.port_mappings.prefetch_related('front_port', 'rear_port')
# Cache front & rear ports for efficient lookups by name
front_ports = {
fp.name: fp for fp in FrontPort.objects.filter(device=device)
}
rear_ports = {
rp.name: rp for rp in RearPort.objects.filter(device=device)
}
# Replicate PortMappings
mappings = []
for template in templates:
front_port = front_ports.get(template.front_port.resolve_name(module=module, device=device))
rear_port = rear_ports.get(template.rear_port.resolve_name(module=module, device=device))
mappings.append(
PortMapping(
device_id=front_port.device_id,
front_port=front_port,
front_port_position=template.front_port_position,
rear_port=rear_port,
rear_port_position=template.rear_port_position,
)
)
# Bulk-created (no per-mapping ObjectChange) to match how every other component is instantiated.
PortMapping.objects.bulk_create(mappings)
def reconcile_port_mappings(mapping_model, parent_field, parent, desired):
"""
Reconcile a parent port's mappings against `desired`, writing only the difference so unchanged
mappings keep their PK (and emit no changelog entry). Changed/removed rows are deleted before
replacements are created, all in one transaction, so position swaps don't trip the unique
constraint. Per-row create()/delete() let the change-logging signals fire naturally.
Args:
mapping_model: PortMapping or PortTemplateMapping.
parent_field: 'front_port' or 'rear_port' — the side being edited; its '<parent_field>_position'
is each mapping's stable identity within the set.
parent: the parent instance (FrontPort/RearPort or their templates).
desired: iterable of dicts of mapping field values EXCLUDING the parent FK, using '<field>_id'
for the opposite-port FK, e.g. {'front_port_position': 1, 'rear_port_id': 5,
'rear_port_position': 2}. save() derives device/device_type/module_type from the front port.
"""
key_field = f'{parent_field}_position'
other_field = 'rear_port' if parent_field == 'front_port' else 'front_port'
value_fields = (f'{other_field}_id', f'{other_field}_position')
def target(source):
# The comparable "value" of a mapping: the opposite port and its position. Two mappings with
# the same parent-side position but a different target represent a re-pointing of that slot.
get = source.get if isinstance(source, dict) else lambda f: getattr(source, f)
return tuple(get(f) for f in value_fields)
desired_by_key = {d[key_field]: d for d in desired}
with transaction.atomic(using=router.db_for_write(mapping_model)):
# Lock the parent's existing mappings for the duration of the reconcile. Two requests editing
# the same port would otherwise read the same snapshot and race, the second colliding on a
# unique constraint when it recreates rows the first has already committed.
existing = {
getattr(m, key_field): m
for m in mapping_model.objects.filter(**{parent_field: parent}).select_for_update()
}
# Delete rows that no longer exist or whose target changed (before creating, to free the slots)
for key, mapping in existing.items():
if key not in desired_by_key or target(mapping) != target(desired_by_key[key]):
mapping.delete()
# Create rows that are new or whose target changed
for key, attrs in desired_by_key.items():
if key not in existing or target(existing[key]) != target(attrs):
mapping_model.objects.create(**{parent_field: parent, **attrs})