diff --git a/netbox/dcim/api/serializers_/devicetype_components.py b/netbox/dcim/api/serializers_/devicetype_components.py index b44565d65..ed2893ed4 100644 --- a/netbox/dcim/api/serializers_/devicetype_components.py +++ b/netbox/dcim/api/serializers_/devicetype_components.py @@ -243,13 +243,13 @@ class FrontPortTemplateSerializer(ComponentTemplateSerializer): default=None ) type = ChoiceField(choices=PortTypeChoices) - rear_port = RearPortTemplateSerializer(nested=True) + rear_ports = RearPortTemplateSerializer(nested=True, many=True) class Meta: model = FrontPortTemplate fields = [ - 'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'color', - 'rear_port', 'rear_port_position', 'description', 'created', 'last_updated', + 'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'color', 'positions', + 'rear_ports', 'description', 'created', 'last_updated', ] brief_fields = ('id', 'url', 'display', 'name', 'description') diff --git a/netbox/dcim/forms/mixins.py b/netbox/dcim/forms/mixins.py index 96eb8a56b..075742642 100644 --- a/netbox/dcim/forms/mixins.py +++ b/netbox/dcim/forms/mixins.py @@ -4,7 +4,7 @@ from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.utils.translation import gettext_lazy as _ from dcim.constants import LOCATION_SCOPE_TYPES -from dcim.models import Site +from dcim.models import PortAssignmentTemplate, Site from utilities.forms import get_field_value from utilities.forms.fields import ( ContentTypeChoiceField, CSVContentTypeField, DynamicModelChoiceField, @@ -13,6 +13,7 @@ from utilities.templatetags.builtins.filters import bettertitle from utilities.forms.widgets import HTMXSelect __all__ = ( + 'FrontPortFormMixin', 'ScopedBulkEditForm', 'ScopedForm', 'ScopedImportForm', @@ -128,3 +129,51 @@ class ScopedImportForm(forms.Form): "Please select a {scope_type}." ).format(scope_type=scope_type.model_class()._meta.model_name) }) + + +class FrontPortFormMixin(forms.Form): + rear_ports = forms.MultipleChoiceField( + choices=[], + label=_('Rear ports'), + widget=forms.SelectMultiple(attrs={'size': 8}) + ) + + port_assignment_model = PortAssignmentTemplate + + def clean(self): + super().clean() + + # FrontPort with no positions cannot be mapped to more than one RearPort + if not self.cleaned_data['positions'] and len(self.cleaned_data['rear_ports']) > 1: + raise forms.ValidationError({ + 'positions': _("A front port with no positions cannot be mapped to multiple rear ports.") + }) + + # 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({ + 'rear_ports': _( + "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 + self.port_assignment_model.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( + self.port_assignment_model( + front_port_id=self.instance.pk, + front_port_position=i, + rear_port_id=rear_port_id, + rear_port_position=rear_port_position, + ) + ) + self.port_assignment_model.objects.bulk_create(assignments) diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index 6d8c4ba42..b7d7351ab 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -6,6 +6,7 @@ from timezone_field import TimeZoneFormField from dcim.choices import * from dcim.constants import * +from dcim.forms.mixins import FrontPortFormMixin from dcim.models import * from extras.models import ConfigTemplate from ipam.choices import VLANQinQRoleChoices @@ -1111,29 +1112,67 @@ class InterfaceTemplateForm(ModularComponentTemplateForm): ] -class FrontPortTemplateForm(ModularComponentTemplateForm): - rear_ports = forms.MultipleChoiceField( - choices=[], - label=_('Rear ports'), - widget=forms.SelectMultiple(attrs={'size': 8}) - ) - +class FrontPortTemplateForm(FrontPortFormMixin, ModularComponentTemplateForm): fieldsets = ( FieldSet( TabbedGroups( FieldSet('device_type', name=_('Device Type')), FieldSet('module_type', name=_('Module Type')), ), - 'name', 'label', 'type', 'color', 'positions', 'rear_ports', 'description', + 'name', 'label', 'positions', 'rear_ports', 'description', ), ) + port_assignment_model = PortAssignmentTemplate + parent_field = 'device_type' + class Meta: model = FrontPortTemplate fields = [ 'device_type', 'module_type', 'name', 'label', 'type', 'color', 'positions', 'description', ] + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + if device_type_id := self.data.get('device_type') or self.initial.get('device_type'): + device_type = DeviceType.objects.get(pk=device_type_id) + else: + return + + # Populate rear port choices + self.fields['rear_ports'].choices = self._get_rear_port_choices(device_type, self.instance) + + # 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 PortAssignmentTemplate.objects.filter(front_port_id=self.instance.pk) + ] + + def _get_rear_port_choices(self, device_type, front_port): + """ + Return a list of choices representing each available rear port & position pair on the device type, excluding + those assigned to the specified instance. + """ + occupied_rear_port_positions = [ + f'{assignment.rear_port_id}:{assignment.rear_port_position}' + for assignment in PortAssignmentTemplate.objects.filter( + front_port__device_type=device_type + ).exclude(front_port=front_port.pk) + ] + + choices = [] + for rear_port in RearPortTemplate.objects.filter(device_type=device_type): + for i in range(1, rear_port.positions + 1): + pair_id = f'{rear_port.pk}:{i}' + if pair_id not in occupied_rear_port_positions: + pair_label = f'{rear_port.name}:{i}' + choices.append( + (pair_id, pair_label) + ) + return choices + class RearPortTemplateForm(ModularComponentTemplateForm): fieldsets = ( @@ -1572,13 +1611,7 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm): } -class FrontPortForm(ModularDeviceComponentForm): - rear_ports = forms.MultipleChoiceField( - choices=[], - label=_('Rear ports'), - widget=forms.SelectMultiple(attrs={'size': 8}) - ) - +class FrontPortForm(FrontPortFormMixin, ModularDeviceComponentForm): fieldsets = ( FieldSet( 'device', 'module', 'name', 'label', 'type', 'color', 'positions', 'rear_ports', 'mark_connected', @@ -1586,6 +1619,8 @@ class FrontPortForm(ModularDeviceComponentForm): ), ) + port_assignment_model = PortAssignment + class Meta: model = FrontPort fields = [ @@ -1611,44 +1646,6 @@ class FrontPortForm(ModularDeviceComponentForm): for assignment in PortAssignment.objects.filter(front_port_id=self.instance.pk) ] - def clean(self): - super().clean() - - # FrontPort with no positions cannot be mapped to more than one RearPort - if not self.cleaned_data['positions'] and len(self.cleaned_data['rear_ports']) > 1: - raise forms.ValidationError({ - 'positions': _("A front port with no positions cannot be mapped to multiple rear ports.") - }) - - # 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({ - 'rear_ports': _( - "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) - def _get_rear_port_choices(self, device, front_port): """ Return a list of choices representing each available rear port & position pair on the device, excluding those diff --git a/netbox/dcim/forms/object_create.py b/netbox/dcim/forms/object_create.py index 028ab6c3c..86e5be279 100644 --- a/netbox/dcim/forms/object_create.py +++ b/netbox/dcim/forms/object_create.py @@ -109,69 +109,37 @@ class InterfaceTemplateCreateForm(ComponentCreateForm, model_forms.InterfaceTemp class FrontPortTemplateCreateForm(ComponentCreateForm, model_forms.FrontPortTemplateForm): - rear_port = forms.MultipleChoiceField( - choices=[], - label=_('Rear ports'), - help_text=_('Select one rear port assignment for each front port being created.'), - widget=forms.SelectMultiple(attrs={'size': 6}) - ) - # Override fieldsets from FrontPortTemplateForm to omit rear_port_position + # Override fieldsets from FrontPortTemplateForm fieldsets = ( FieldSet( TabbedGroups( FieldSet('device_type', name=_('Device Type')), FieldSet('module_type', name=_('Module Type')), ), - 'name', 'label', 'type', 'color', 'rear_port', 'description', + 'name', 'label', 'type', 'color', 'positions', 'rear_ports', 'description', ), ) - class Meta(model_forms.FrontPortTemplateForm.Meta): - exclude = ('name', 'label', 'rear_port', 'rear_port_position') - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - # TODO: This needs better validation - if 'device_type' in self.initial or self.data.get('device_type'): - parent = DeviceType.objects.get( - pk=self.initial.get('device_type') or self.data.get('device_type') - ) - elif 'module_type' in self.initial or self.data.get('module_type'): - parent = ModuleType.objects.get( - pk=self.initial.get('module_type') or self.data.get('module_type') - ) - else: - return - - # Determine which rear port positions are occupied. These will be excluded from the list of available mappings. - occupied_port_positions = [ - (front_port.rear_port_id, front_port.rear_port_position) - for front_port in parent.frontporttemplates.all() - ] - - # Populate rear port choices - choices = [] - rear_ports = parent.rearporttemplates.all() - for rear_port in rear_ports: - for i in range(1, rear_port.positions + 1): - if (rear_port.pk, i) not in occupied_port_positions: - choices.append( - ('{}:{}'.format(rear_port.pk, i), '{}:{}'.format(rear_port.name, i)) - ) - self.fields['rear_port'].choices = choices + class Meta: + model = FrontPortTemplate + fields = ( + 'device_type', 'module_type', 'type', 'color', 'positions', 'description', + ) def clean(self): - super().clean() + # TODO + # super(ComponentCreateForm, self).clean() # Check that the number of FrontPortTemplates to be created matches the selected number of RearPortTemplate # positions + print(f"name: {self.cleaned_data['name']}") + print(f"rear_ports: {self.cleaned_data['rear_ports']}") frontport_count = len(self.cleaned_data['name']) - rearport_count = len(self.cleaned_data['rear_port']) + rearport_count = len(self.cleaned_data['rear_ports']) if frontport_count != rearport_count: raise forms.ValidationError({ - 'rear_port': _( + 'rear_ports': _( "The number of front port templates to be created ({frontport_count}) must match the selected " "number of rear port positions ({rearport_count})." ).format( @@ -181,13 +149,11 @@ class FrontPortTemplateCreateForm(ComponentCreateForm, model_forms.FrontPortTemp }) def get_iterative_data(self, iteration): - - # Assign rear port and position from selected set - rear_port, position = self.cleaned_data['rear_port'][iteration].split(':') + positions = self.cleaned_data['positions'] + offset = positions * iteration return { - 'rear_port': int(rear_port), - 'rear_port_position': int(position), + 'rear_ports': self.cleaned_data['rear_ports'][offset:offset + positions] } diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index dc3146161..317e77452 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -19,7 +19,7 @@ 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_assignments, update_interface_bridges from extras.models import ConfigContextModel, CustomField from extras.querysets import ConfigContextModelQuerySet from netbox.choices import ColorChoices @@ -30,6 +30,7 @@ from netbox.models.features import ContactsMixin, ImageAttachmentsMixin from utilities.fields import ColorField, CounterCacheField from utilities.prefetch import get_prefetchable_fields from utilities.tracking import TrackingModelMixin +from . import PortAssignmentTemplate from .device_components import * from .mixins import RenderConfigMixin from .modules import Module @@ -1008,6 +1009,10 @@ class Device( self._instantiate_components(self.device_type.inventoryitemtemplates.all(), bulk_create=False) # Interface bridges have to be set after interface instantiation update_interface_bridges(self, self.device_type.interfacetemplates.all()) + # Replicate any front/rear port assignments from the DeviceType + create_port_assignments(self, PortAssignmentTemplate.objects.filter( + front_port__device_type=self.device_type + )) # Update Site and Rack assignment for any child Devices devices = Device.objects.filter(parent_bay__device=self) diff --git a/netbox/dcim/utils.py b/netbox/dcim/utils.py index 2380fbd0d..50963890f 100644 --- a/netbox/dcim/utils.py +++ b/netbox/dcim/utils.py @@ -83,3 +83,23 @@ def update_interface_bridges(device, interface_templates, module=None): ) interface.full_clean() interface.save() + + +def create_port_assignments(device, templates, module=None): + """ + Used for device and module instantiation. Replicate all front/rear port assignments from a DeviceType to the given + device. + """ + from dcim.models.device_components import FrontPort, PortAssignment, RearPort + + for template in templates: + front_port = FrontPort.objects.get(device=device, name=template.front_port.resolve_name(module=module)) + rear_port = RearPort.objects.get(device=device, name=template.rear_port.resolve_name(module=module)) + + assignment = PortAssignment( + front_port=front_port, + front_port_position=template.front_port_position, + rear_port=rear_port, + rear_port_position=template.rear_port_position, + ) + assignment.save()