diff --git a/netbox/dcim/api/serializers_/device_components.py b/netbox/dcim/api/serializers_/device_components.py
index ecd8207e3..22fe4777c 100644
--- a/netbox/dcim/api/serializers_/device_components.py
+++ b/netbox/dcim/api/serializers_/device_components.py
@@ -333,14 +333,14 @@ class FrontPortSerializer(NetBoxModelSerializer, CabledObjectSerializer):
allow_null=True
)
type = ChoiceField(choices=PortTypeChoices)
- rear_port = FrontPortRearPortSerializer()
+ rear_ports = FrontPortRearPortSerializer(many=True)
class Meta:
model = FrontPort
fields = [
'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'label', 'type', 'color', 'positions',
- 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', 'tags',
- 'custom_fields', 'created', 'last_updated', '_occupied',
+ 'rear_ports', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type',
+ 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
]
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py
index af0662d1e..028315211 100644
--- a/netbox/dcim/forms/model_forms.py
+++ b/netbox/dcim/forms/model_forms.py
@@ -1578,16 +1578,15 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
class FrontPortForm(ModularDeviceComponentForm):
- rear_port = DynamicModelChoiceField(
- queryset=RearPort.objects.all(),
- query_params={
- 'device_id': '$device',
- }
+ rear_ports = forms.MultipleChoiceField(
+ choices=[],
+ label=_('Rear ports'),
+ widget=forms.SelectMultiple(attrs={'size': 6})
)
fieldsets = (
FieldSet(
- 'device', 'module', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'mark_connected',
+ 'device', 'module', 'name', 'label', 'type', 'color', 'positions', 'rear_ports', 'mark_connected',
'description', 'tags',
),
)
@@ -1599,6 +1598,59 @@ class FrontPortForm(ModularDeviceComponentForm):
'tags',
]
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+
+ if device_id := (self.data.get('device') or self.initial.get('device')):
+ device = Device.objects.get(pk=device_id)
+ else:
+ return
+
+ # Populate rear port choices
+ choices = []
+ for rear_port in RearPort.objects.filter(device=device):
+ for i in range(1, rear_port.positions + 1):
+ choices.append(
+ ('{}:{}'.format(rear_port.pk, i), '{}:{}'.format(rear_port.name, i))
+ )
+ self.fields['rear_ports'].choices = choices
+
+ # Set initial rear port assignments
+ if self.instance.pk:
+ self.initial['rear_ports'] = [
+ f'{assignment.rear_port_id}:{assignment.rear_port_position}'
+ for assignment in PortAssignment.objects.filter(front_port_id=self.instance.pk)
+ ]
+
+ def clean(self):
+
+ # Count of selected rear port & position pairs much match the assigned number of positions
+ if len(self.cleaned_data['rear_ports']) != self.cleaned_data['positions']:
+ raise forms.ValidationError(
+ _("The number of rear port/position pairs selected must match the number of positions assigned.")
+ )
+
+ def _save_m2m(self):
+ super()._save_m2m()
+
+ # TODO: Can this be made more efficient?
+ # Delete existing rear port assignments
+ PortAssignment.objects.filter(front_port_id=self.instance.pk).delete()
+
+ # Create new rear port assignments
+ assignments = []
+ for i, rp_position in enumerate(self.cleaned_data['rear_ports'], start=1):
+ rear_port_id, rear_port_position = rp_position.split(':')
+ assignments.append(
+ PortAssignment(
+ front_port_id=self.instance.pk,
+ front_port_position=i,
+ rear_port_id=rear_port_id,
+ rear_port_position=rear_port_position,
+ )
+ )
+ PortAssignment.objects.bulk_create(assignments)
+
class RearPortForm(ModularDeviceComponentForm):
fieldsets = (
diff --git a/netbox/dcim/migrations/0222_frontport_positions.py b/netbox/dcim/migrations/0222_frontport_positions.py
index 2ef7ff088..93421ee47 100644
--- a/netbox/dcim/migrations/0222_frontport_positions.py
+++ b/netbox/dcim/migrations/0222_frontport_positions.py
@@ -9,6 +9,10 @@ class Migration(migrations.Migration):
]
operations = [
+ migrations.RemoveConstraint(
+ model_name='frontport',
+ name='dcim_frontport_unique_rear_port_position',
+ ),
migrations.RemoveField(
model_name='frontport',
name='rear_port',
diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py
index f906add7c..3c054891b 100644
--- a/netbox/dcim/models/device_components.py
+++ b/netbox/dcim/models/device_components.py
@@ -35,6 +35,7 @@ __all__ = (
'InventoryItemRole',
'ModuleBay',
'PathEndpoint',
+ 'PortAssignment',
'PowerOutlet',
'PowerPort',
'RearPort',
@@ -1106,6 +1107,29 @@ class PortAssignment(models.Model):
),
)
+ def clean(self):
+
+ # Validate rear port assignment
+ 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
+ )
+ })
+
+ # 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
+ )
+ })
+
class FrontPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin):
"""
@@ -1142,40 +1166,10 @@ 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')
- def clean(self):
- super().clean()
-
- if hasattr(self, 'rear_port'):
-
- # Validate rear port assignment
- if self.rear_port.device != self.device:
- 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
- )
- })
-
class RearPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin):
"""
diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py
index 463d98179..a3d0b2ded 100644
--- a/netbox/dcim/views.py
+++ b/netbox/dcim/views.py
@@ -42,6 +42,7 @@ from wireless.models import WirelessLAN
from . import filtersets, forms, tables
from .choices import DeviceFaceChoices, InterfaceModeChoices
from .models import *
+from .models.device_components import PortAssignment
from .object_actions import BulkAddComponents, BulkDisconnect
CABLE_TERMINATION_TYPES = {
@@ -3242,6 +3243,11 @@ class FrontPortListView(generic.ObjectListView):
class FrontPortView(generic.ObjectView):
queryset = FrontPort.objects.all()
+ def get_extra_context(self, request, instance):
+ return {
+ 'rear_port_assignments': PortAssignment.objects.filter(front_port=instance).prefetch_related('rear_port'),
+ }
+
@register_model_view(FrontPort, 'add', detail=False)
class FrontPortCreateView(generic.ComponentCreateView):
@@ -3313,6 +3319,11 @@ class RearPortListView(generic.ObjectListView):
class RearPortView(generic.ObjectView):
queryset = RearPort.objects.all()
+ def get_extra_context(self, request, instance):
+ return {
+ 'front_port_assignments': PortAssignment.objects.filter(rear_port=instance).prefetch_related('front_port'),
+ }
+
@register_model_view(RearPort, 'add', detail=False)
class RearPortCreateView(generic.ComponentCreateView):
diff --git a/netbox/templates/dcim/frontport.html b/netbox/templates/dcim/frontport.html
index dc68fbfa9..180c6c3e2 100644
--- a/netbox/templates/dcim/frontport.html
+++ b/netbox/templates/dcim/frontport.html
@@ -61,6 +61,18 @@
{% plugin_left_page object %}
+
+
+
+ {% for assignment in rear_port_assignments %}
+
+ | {{ assignment.front_port_position }} |
+ {{ assignment.rear_port|linkify }} |
+ {{ assignment.rear_port_position }} |
+
+ {% endfor %}
+
+
{% if object.mark_connected %}
diff --git a/netbox/templates/dcim/rearport.html b/netbox/templates/dcim/rearport.html
index 3fed4307b..6b8236218 100644
--- a/netbox/templates/dcim/rearport.html
+++ b/netbox/templates/dcim/rearport.html
@@ -61,6 +61,18 @@
{% plugin_left_page object %}
+
+
+
+ {% for assignment in front_port_assignments %}
+
+ | {{ assignment.rear_port_position }} |
+ {{ assignment.front_port|linkify }} |
+ {{ assignment.front_port_position }} |
+
+ {% endfor %}
+
+
{% if object.mark_connected %}