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 %}
+
+

{% trans "Rear Ports" %}

+ + {% for assignment in rear_port_assignments %} + + + + + + {% endfor %} +
{{ assignment.front_port_position }}{{ assignment.rear_port|linkify }}{{ assignment.rear_port_position }}
+

{% trans "Connection" %}

{% 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 %}
+
+

{% trans "Rear Ports" %}

+ + {% for assignment in front_port_assignments %} + + + + + + {% endfor %} +
{{ assignment.rear_port_position }}{{ assignment.front_port|linkify }}{{ assignment.front_port_position }}
+

{% trans "Connection" %}

{% if object.mark_connected %}