Closes #20564: Many-to-many pass-through port mappings (#20851)

This commit is contained in:
Jeremy Stretch
2025-12-09 12:17:17 -05:00
committed by GitHub
parent 97d0a16fd4
commit 17d8f78ae3
35 changed files with 2512 additions and 941 deletions

View File

@@ -0,0 +1,61 @@
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.utils.translation import gettext_lazy as _
from dcim.constants import PORT_POSITION_MAX, PORT_POSITION_MIN
__all__ = (
'PortMappingBase',
)
class PortMappingBase(models.Model):
"""
Base class for PortMapping and PortTemplateMapping
"""
front_port_position = models.PositiveSmallIntegerField(
default=1,
validators=(
MinValueValidator(PORT_POSITION_MIN),
MaxValueValidator(PORT_POSITION_MAX),
),
)
rear_port_position = models.PositiveSmallIntegerField(
default=1,
validators=(
MinValueValidator(PORT_POSITION_MIN),
MaxValueValidator(PORT_POSITION_MAX),
),
)
_netbox_private = True
class Meta:
abstract = True
constraints = (
models.UniqueConstraint(
fields=('front_port', 'front_port_position'),
name='%(app_label)s_%(class)s_unique_front_port_position'
),
models.UniqueConstraint(
fields=('rear_port', 'rear_port_position'),
name='%(app_label)s_%(class)s_unique_rear_port_position'
),
)
def clean(self):
super().clean()
# Validate rear port position
if self.rear_port_position > self.rear_port.positions:
raise ValidationError({
"rear_port_position": _(
"Invalid rear port position ({rear_port_position}): Rear port {name} has only {positions} "
"positions."
).format(
rear_port_position=self.rear_port_position,
name=self.rear_port.name,
positions=self.rear_port.positions
)
})

View File

@@ -1,4 +1,5 @@
import itertools
import logging
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
@@ -22,7 +23,7 @@ from utilities.fields import ColorField, GenericArrayForeignKey
from utilities.querysets import RestrictedQuerySet
from utilities.serialization import deserialize_object, serialize_object
from wireless.models import WirelessLink
from .device_components import FrontPort, PathEndpoint, RearPort
from .device_components import FrontPort, PathEndpoint, PortMapping, RearPort
__all__ = (
'Cable',
@@ -30,6 +31,8 @@ __all__ = (
'CableTermination',
)
logger = logging.getLogger(f'netbox.{__name__}')
trace_paths = Signal()
@@ -666,7 +669,13 @@ class CablePath(models.Model):
is_active = True
is_split = False
logger.debug(f'Tracing cable path from {terminations}...')
segment = 0
while terminations:
segment += 1
logger.debug(f'[Path segment #{segment}] Position stack: {position_stack}')
logger.debug(f'[Path segment #{segment}] Local terminations: {terminations}')
# Terminations must all be of the same type
if not all(isinstance(t, type(terminations[0])) for t in terminations[1:]):
@@ -697,7 +706,10 @@ class CablePath(models.Model):
position_stack.append([terminations[0].cable_position])
# Step 2: Determine the attached links (Cable or WirelessLink), if any
links = [termination.link for termination in terminations if termination.link is not None]
links = list(dict.fromkeys(
termination.link for termination in terminations if termination.link is not None
))
logger.debug(f'[Path segment #{segment}] Links: {links}')
if len(links) == 0:
if len(path) == 1:
# If this is the start of the path and no link exists, return None
@@ -760,10 +772,13 @@ class CablePath(models.Model):
link.interface_b if link.interface_a is terminations[0] else link.interface_a for link in links
]
logger.debug(f'[Path segment #{segment}] Remote terminations: {remote_terminations}')
# Remote Terminations must all be of the same type, otherwise return a split path
if not all(isinstance(t, type(remote_terminations[0])) for t in remote_terminations[1:]):
is_complete = False
is_split = True
logger.debug('Remote termination types differ; aborting trace.')
break
# Step 7: Record the far-end termination object(s)
@@ -777,58 +792,53 @@ class CablePath(models.Model):
if isinstance(remote_terminations[0], FrontPort):
# Follow FrontPorts to their corresponding RearPorts
rear_ports = RearPort.objects.filter(
pk__in=[t.rear_port_id for t in remote_terminations]
)
if len(rear_ports) > 1 or rear_ports[0].positions > 1:
position_stack.append([fp.rear_port_position for fp in remote_terminations])
terminations = rear_ports
elif isinstance(remote_terminations[0], RearPort):
if len(remote_terminations) == 1 and remote_terminations[0].positions == 1:
front_ports = FrontPort.objects.filter(
rear_port_id__in=[rp.pk for rp in remote_terminations],
rear_port_position=1
)
# Obtain the individual front ports based on the termination and all positions
elif len(remote_terminations) > 1 and position_stack:
if remote_terminations[0].positions > 1 and position_stack:
positions = position_stack.pop()
# Ensure we have a number of positions equal to the amount of remote terminations
if len(remote_terminations) != len(positions):
raise UnsupportedCablePath(
_("All positions counts within the path on opposite ends of links must match")
)
# Get our front ports
q_filter = Q()
for rt in remote_terminations:
position = positions.pop()
q_filter |= Q(rear_port_id=rt.pk, rear_port_position=position)
if q_filter is Q():
raise UnsupportedCablePath(_("Remote termination position filter is missing"))
front_ports = FrontPort.objects.filter(q_filter)
# Obtain the individual front ports based on the termination and position
elif position_stack:
front_ports = FrontPort.objects.filter(
rear_port_id=remote_terminations[0].pk,
rear_port_position__in=position_stack.pop()
)
# If all rear ports have a single position, we can just get the front ports
elif all([rp.positions == 1 for rp in remote_terminations]):
front_ports = FrontPort.objects.filter(rear_port_id__in=[rp.pk for rp in remote_terminations])
if len(front_ports) != len(remote_terminations):
# Some rear ports does not have a front port
is_split = True
break
else:
# No position indicated: path has split, so we stop at the RearPorts
q_filter |= Q(front_port=rt, front_port_position__in=positions)
port_mappings = PortMapping.objects.filter(q_filter)
elif remote_terminations[0].positions > 1:
is_split = True
logger.debug(
'Encountered front port mapped to multiple rear ports but position stack is empty; aborting '
'trace.'
)
break
else:
port_mappings = PortMapping.objects.filter(front_port__in=remote_terminations)
if not port_mappings:
break
terminations = front_ports
# Compile the list of RearPorts without duplication or altering their ordering
terminations = list(dict.fromkeys(mapping.rear_port for mapping in port_mappings))
if any(t.positions > 1 for t in terminations):
position_stack.append([mapping.rear_port_position for mapping in port_mappings])
elif isinstance(remote_terminations[0], RearPort):
# Follow RearPorts to their corresponding FrontPorts
if remote_terminations[0].positions > 1 and position_stack:
positions = position_stack.pop()
q_filter = Q()
for rt in remote_terminations:
q_filter |= Q(rear_port=rt, rear_port_position__in=positions)
port_mappings = PortMapping.objects.filter(q_filter)
elif remote_terminations[0].positions > 1:
is_split = True
logger.debug(
'Encountered rear port mapped to multiple front ports but position stack is empty; aborting '
'trace.'
)
break
else:
port_mappings = PortMapping.objects.filter(rear_port__in=remote_terminations)
if not port_mappings:
break
# Compile the list of FrontPorts without duplication or altering their ordering
terminations = list(dict.fromkeys(mapping.front_port for mapping in port_mappings))
if any(t.positions > 1 for t in terminations):
position_stack.append([mapping.front_port_position for mapping in port_mappings])
elif isinstance(remote_terminations[0], CircuitTermination):
# Follow a CircuitTermination to its corresponding CircuitTermination (A to Z or vice versa)
@@ -876,6 +886,7 @@ class CablePath(models.Model):
# Unsupported topology, mark as split and exit
is_complete = False
is_split = True
logger.warning('Encountered an unsupported topology; aborting trace.')
break
return cls(
@@ -954,16 +965,23 @@ class CablePath(models.Model):
# RearPort splitting to multiple FrontPorts with no stack position
if type(nodes[0]) is RearPort:
return FrontPort.objects.filter(rear_port__in=nodes)
return [
mapping.front_port for mapping in
PortMapping.objects.filter(rear_port__in=nodes).prefetch_related('front_port')
]
# Cable terminating to multiple FrontPorts mapped to different
# RearPorts connected to different cables
elif type(nodes[0]) is FrontPort:
return RearPort.objects.filter(pk__in=[fp.rear_port_id for fp in nodes])
if type(nodes[0]) is FrontPort:
return [
mapping.rear_port for mapping in
PortMapping.objects.filter(front_port__in=nodes).prefetch_related('rear_port')
]
# Cable terminating to multiple CircuitTerminations
elif type(nodes[0]) is CircuitTermination:
if type(nodes[0]) is CircuitTermination:
return [
ct.get_peer_termination() for ct in nodes
]
return []
def get_asymmetric_nodes(self):
"""

View File

@@ -7,6 +7,7 @@ from mptt.models import MPTTModel, TreeForeignKey
from dcim.choices import *
from dcim.constants import *
from dcim.models.base import PortMappingBase
from dcim.models.mixins import InterfaceValidationMixin
from netbox.models import ChangeLoggedModel
from utilities.fields import ColorField, NaturalOrderingField
@@ -28,6 +29,7 @@ __all__ = (
'InterfaceTemplate',
'InventoryItemTemplate',
'ModuleBayTemplate',
'PortTemplateMapping',
'PowerOutletTemplate',
'PowerPortTemplate',
'RearPortTemplate',
@@ -518,6 +520,53 @@ class InterfaceTemplate(InterfaceValidationMixin, ModularComponentTemplateModel)
}
class PortTemplateMapping(PortMappingBase):
"""
Maps a FrontPortTemplate & position to a RearPortTemplate & position.
"""
device_type = models.ForeignKey(
to='dcim.DeviceType',
on_delete=models.CASCADE,
related_name='port_mappings',
blank=True,
null=True,
)
module_type = models.ForeignKey(
to='dcim.ModuleType',
on_delete=models.CASCADE,
related_name='port_mappings',
blank=True,
null=True,
)
front_port = models.ForeignKey(
to='dcim.FrontPortTemplate',
on_delete=models.CASCADE,
related_name='mappings',
)
rear_port = models.ForeignKey(
to='dcim.RearPortTemplate',
on_delete=models.CASCADE,
related_name='mappings',
)
def clean(self):
super().clean()
# Validate rear port assignment
if self.front_port.device_type_id != self.rear_port.device_type_id:
raise ValidationError({
"rear_port": _("Rear port ({rear_port}) must belong to the same device type").format(
rear_port=self.rear_port
)
})
def save(self, *args, **kwargs):
# Associate the mapping with the parent DeviceType/ModuleType
self.device_type = self.front_port.device_type
self.module_type = self.front_port.module_type
super().save(*args, **kwargs)
class FrontPortTemplate(ModularComponentTemplateModel):
"""
Template for a pass-through port on the front of a new Device.
@@ -531,18 +580,13 @@ class FrontPortTemplate(ModularComponentTemplateModel):
verbose_name=_('color'),
blank=True
)
rear_port = models.ForeignKey(
to='dcim.RearPortTemplate',
on_delete=models.CASCADE,
related_name='frontport_templates'
)
rear_port_position = models.PositiveSmallIntegerField(
verbose_name=_('rear port position'),
positions = models.PositiveSmallIntegerField(
verbose_name=_('positions'),
default=1,
validators=[
MinValueValidator(REARPORT_POSITIONS_MIN),
MaxValueValidator(REARPORT_POSITIONS_MAX)
]
MinValueValidator(PORT_POSITION_MIN),
MaxValueValidator(PORT_POSITION_MAX)
],
)
component_model = FrontPort
@@ -557,10 +601,6 @@ class FrontPortTemplate(ModularComponentTemplateModel):
fields=('module_type', 'name'),
name='%(app_label)s_%(class)s_unique_module_type_name'
),
models.UniqueConstraint(
fields=('rear_port', 'rear_port_position'),
name='%(app_label)s_%(class)s_unique_rear_port_position'
),
)
verbose_name = _('front port template')
verbose_name_plural = _('front port templates')
@@ -568,40 +608,23 @@ class FrontPortTemplate(ModularComponentTemplateModel):
def clean(self):
super().clean()
try:
# Validate rear port assignment
if self.rear_port.device_type != self.device_type:
raise ValidationError(
_("Rear port ({name}) must belong to the same device type").format(name=self.rear_port)
)
# Validate rear port position assignment
if self.rear_port_position > self.rear_port.positions:
raise ValidationError(
_("Invalid rear port position ({position}); rear port {name} has only {count} positions").format(
position=self.rear_port_position,
name=self.rear_port.name,
count=self.rear_port.positions
)
)
except RearPortTemplate.DoesNotExist:
pass
# Check that positions is greater than or equal to the number of associated RearPortTemplates
if not self._state.adding:
mapping_count = self.mappings.count()
if self.positions < mapping_count:
raise ValidationError({
"positions": _(
"The number of positions cannot be less than the number of mapped rear port templates ({count})"
).format(count=mapping_count)
})
def instantiate(self, **kwargs):
if self.rear_port:
rear_port_name = self.rear_port.resolve_name(kwargs.get('module'))
rear_port = RearPort.objects.get(name=rear_port_name, **kwargs)
else:
rear_port = None
return self.component_model(
name=self.resolve_name(kwargs.get('module')),
label=self.resolve_label(kwargs.get('module')),
type=self.type,
color=self.color,
rear_port=rear_port,
rear_port_position=self.rear_port_position,
positions=self.positions,
**kwargs
)
instantiate.do_not_call_in_templates = True
@@ -611,8 +634,7 @@ class FrontPortTemplate(ModularComponentTemplateModel):
'name': self.name,
'type': self.type,
'color': self.color,
'rear_port': self.rear_port.name,
'rear_port_position': self.rear_port_position,
'positions': self.positions,
'label': self.label,
'description': self.description,
}
@@ -635,9 +657,9 @@ class RearPortTemplate(ModularComponentTemplateModel):
verbose_name=_('positions'),
default=1,
validators=[
MinValueValidator(REARPORT_POSITIONS_MIN),
MaxValueValidator(REARPORT_POSITIONS_MAX)
]
MinValueValidator(PORT_POSITION_MIN),
MaxValueValidator(PORT_POSITION_MAX)
],
)
component_model = RearPort
@@ -646,6 +668,20 @@ class RearPortTemplate(ModularComponentTemplateModel):
verbose_name = _('rear port template')
verbose_name_plural = _('rear port templates')
def clean(self):
super().clean()
# Check that positions is greater than or equal to the number of associated FrontPortTemplates
if not self._state.adding:
mapping_count = self.mappings.count()
if self.positions < mapping_count:
raise ValidationError({
"positions": _(
"The number of positions cannot be less than the number of mapped front port templates "
"({count})"
).format(count=mapping_count)
})
def instantiate(self, **kwargs):
return self.component_model(
name=self.resolve_name(kwargs.get('module')),

View File

@@ -11,6 +11,7 @@ from mptt.models import MPTTModel, TreeForeignKey
from dcim.choices import *
from dcim.constants import *
from dcim.fields import WWNField
from dcim.models.base import PortMappingBase
from dcim.models.mixins import InterfaceValidationMixin
from netbox.choices import ColorChoices
from netbox.models import OrganizationalModel, NetBoxModel
@@ -35,6 +36,7 @@ __all__ = (
'InventoryItemRole',
'ModuleBay',
'PathEndpoint',
'PortMapping',
'PowerOutlet',
'PowerPort',
'RearPort',
@@ -208,10 +210,6 @@ class CabledObjectModel(models.Model):
raise ValidationError({
"cable_end": _("Must specify cable end (A or B) when attaching a cable.")
})
if not self.cable_position:
raise ValidationError({
"cable_position": _("Must specify cable termination position when attaching a cable.")
})
if self.cable_end and not self.cable:
raise ValidationError({
"cable_end": _("Cable end must not be set without a cable.")
@@ -1069,6 +1067,43 @@ class Interface(
# Pass-through ports
#
class PortMapping(PortMappingBase):
"""
Maps a FrontPort & position to a RearPort & position.
"""
device = models.ForeignKey(
to='dcim.Device',
on_delete=models.CASCADE,
related_name='port_mappings',
)
front_port = models.ForeignKey(
to='dcim.FrontPort',
on_delete=models.CASCADE,
related_name='mappings',
)
rear_port = models.ForeignKey(
to='dcim.RearPort',
on_delete=models.CASCADE,
related_name='mappings',
)
def clean(self):
super().clean()
# Both ports must belong to the same device
if self.front_port.device_id != self.rear_port.device_id:
raise ValidationError({
"rear_port": _("Rear port ({rear_port}) must belong to the same device").format(
rear_port=self.rear_port
)
})
def save(self, *args, **kwargs):
# Associate the mapping with the parent Device
self.device = self.front_port.device
super().save(*args, **kwargs)
class FrontPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin):
"""
A pass-through port on the front of a Device.
@@ -1082,22 +1117,16 @@ class FrontPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin):
verbose_name=_('color'),
blank=True
)
rear_port = models.ForeignKey(
to='dcim.RearPort',
on_delete=models.CASCADE,
related_name='frontports'
)
rear_port_position = models.PositiveSmallIntegerField(
verbose_name=_('rear port position'),
positions = models.PositiveSmallIntegerField(
verbose_name=_('positions'),
default=1,
validators=[
MinValueValidator(REARPORT_POSITIONS_MIN),
MaxValueValidator(REARPORT_POSITIONS_MAX)
MinValueValidator(PORT_POSITION_MIN),
MaxValueValidator(PORT_POSITION_MAX)
],
help_text=_('Mapped position on corresponding rear port')
)
clone_fields = ('device', 'type', 'color')
clone_fields = ('device', 'type', 'color', 'positions')
class Meta(ModularComponentModel.Meta):
constraints = (
@@ -1105,10 +1134,6 @@ class FrontPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin):
fields=('device', 'name'),
name='%(app_label)s_%(class)s_unique_device_name'
),
models.UniqueConstraint(
fields=('rear_port', 'rear_port_position'),
name='%(app_label)s_%(class)s_unique_rear_port_position'
),
)
verbose_name = _('front port')
verbose_name_plural = _('front ports')
@@ -1116,27 +1141,14 @@ class FrontPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin):
def clean(self):
super().clean()
if hasattr(self, 'rear_port'):
# Validate rear port assignment
if self.rear_port.device != self.device:
# Check that positions is greater than or equal to the number of associated RearPorts
if not self._state.adding:
mapping_count = self.mappings.count()
if self.positions < mapping_count:
raise ValidationError({
"rear_port": _(
"Rear port ({rear_port}) must belong to the same device"
).format(rear_port=self.rear_port)
})
# Validate rear port position assignment
if self.rear_port_position > self.rear_port.positions:
raise ValidationError({
"rear_port_position": _(
"Invalid rear port position ({rear_port_position}): Rear port {name} has only {positions} "
"positions."
).format(
rear_port_position=self.rear_port_position,
name=self.rear_port.name,
positions=self.rear_port.positions
)
"positions": _(
"The number of positions cannot be less than the number of mapped rear ports ({count})"
).format(count=mapping_count)
})
@@ -1157,11 +1169,11 @@ class RearPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin):
verbose_name=_('positions'),
default=1,
validators=[
MinValueValidator(REARPORT_POSITIONS_MIN),
MaxValueValidator(REARPORT_POSITIONS_MAX)
MinValueValidator(PORT_POSITION_MIN),
MaxValueValidator(PORT_POSITION_MAX)
],
help_text=_('Number of front ports which may be mapped')
)
clone_fields = ('device', 'type', 'color', 'positions')
class Meta(ModularComponentModel.Meta):
@@ -1173,13 +1185,13 @@ class RearPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin):
# Check that positions count is greater than or equal to the number of associated FrontPorts
if not self._state.adding:
frontport_count = self.frontports.count()
if self.positions < frontport_count:
mapping_count = self.mappings.count()
if self.positions < mapping_count:
raise ValidationError({
"positions": _(
"The number of positions cannot be less than the number of mapped front ports "
"({frontport_count})"
).format(frontport_count=frontport_count)
"({count})"
).format(count=mapping_count)
})

View File

@@ -1,8 +1,7 @@
import decimal
import yaml
from functools import cached_property
import yaml
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
@@ -19,14 +18,14 @@ from django.utils.translation import gettext_lazy as _
from dcim.choices import *
from dcim.constants import *
from dcim.fields import MACAddressField
from dcim.utils import update_interface_bridges
from dcim.utils import create_port_mappings, update_interface_bridges
from extras.models import ConfigContextModel, CustomField
from extras.querysets import ConfigContextModelQuerySet
from netbox.choices import ColorChoices
from netbox.config import ConfigItem
from netbox.models import NestedGroupModel, OrganizationalModel, PrimaryModel
from netbox.models.mixins import WeightMixin
from netbox.models.features import ContactsMixin, ImageAttachmentsMixin
from netbox.models.mixins import WeightMixin
from utilities.fields import ColorField, CounterCacheField
from utilities.prefetch import get_prefetchable_fields
from utilities.tracking import TrackingModelMixin
@@ -34,7 +33,6 @@ from .device_components import *
from .mixins import RenderConfigMixin
from .modules import Module
__all__ = (
'Device',
'DeviceRole',
@@ -1009,6 +1007,8 @@ class Device(
self._instantiate_components(self.device_type.interfacetemplates.all())
self._instantiate_components(self.device_type.rearporttemplates.all())
self._instantiate_components(self.device_type.frontporttemplates.all())
# Replicate any front/rear port mappings from the DeviceType
create_port_mappings(self, self.device_type)
# Disable bulk_create to accommodate MPTT
self._instantiate_components(self.device_type.modulebaytemplates.all(), bulk_create=False)
self._instantiate_components(self.device_type.devicebaytemplates.all())