From f067122ccd945d1610ccea7bbac76012eb1dc0b7 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 20 Nov 2025 15:32:11 -0500 Subject: [PATCH] Add PortAssignmentTemplate for device types --- netbox/dcim/filtersets.py | 13 +- netbox/dcim/forms/model_forms.py | 19 +-- netbox/dcim/forms/object_import.py | 22 +-- .../migrations/0221_m2m_port_assignments.py | 127 ++++++++++++++++-- .../migrations/0222_frontport_positions.py | 30 +++++ .../dcim/models/device_component_templates.py | 120 ++++++++++------- 6 files changed, 235 insertions(+), 96 deletions(-) diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 52ee68929..4a7463fdd 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -884,13 +884,14 @@ class FrontPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeCo choices=PortTypeChoices, null_value=None ) - rear_port_id = django_filters.ModelMultipleChoiceFilter( - queryset=RearPort.objects.all() - ) + # TODO + # rear_port_id = django_filters.ModelMultipleChoiceFilter( + # queryset=RearPortTemplate.objects.all() + # ) class Meta: model = FrontPortTemplate - fields = ('id', 'name', 'label', 'type', 'color', 'rear_port_position', 'description') + fields = ('id', 'name', 'label', 'type', 'color', 'positions', 'description') class RearPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet): @@ -898,6 +899,10 @@ class RearPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeCom choices=PortTypeChoices, null_value=None ) + # TODO + # front_port_id = django_filters.ModelMultipleChoiceFilter( + # queryset=FrontPortTemplate.objects.all() + # ) class Meta: model = RearPortTemplate diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index ce7721f3d..6d8c4ba42 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -1112,14 +1112,10 @@ class InterfaceTemplateForm(ModularComponentTemplateForm): class FrontPortTemplateForm(ModularComponentTemplateForm): - rear_port = DynamicModelChoiceField( - label=_('Rear port'), - queryset=RearPortTemplate.objects.all(), - required=False, - query_params={ - 'device_type_id': '$device_type', - 'module_type_id': '$module_type', - } + rear_ports = forms.MultipleChoiceField( + choices=[], + label=_('Rear ports'), + widget=forms.SelectMultiple(attrs={'size': 8}) ) fieldsets = ( @@ -1128,15 +1124,14 @@ class FrontPortTemplateForm(ModularComponentTemplateForm): FieldSet('device_type', name=_('Device Type')), FieldSet('module_type', name=_('Module Type')), ), - 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description', + 'name', 'label', 'type', 'color', 'positions', 'rear_ports', 'description', ), ) class Meta: model = FrontPortTemplate fields = [ - 'device_type', 'module_type', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', - 'description', + 'device_type', 'module_type', 'name', 'label', 'type', 'color', 'positions', 'description', ] @@ -1581,7 +1576,7 @@ class FrontPortForm(ModularDeviceComponentForm): rear_ports = forms.MultipleChoiceField( choices=[], label=_('Rear ports'), - widget=forms.SelectMultiple(attrs={'size': 6}) + widget=forms.SelectMultiple(attrs={'size': 8}) ) fieldsets = ( diff --git a/netbox/dcim/forms/object_import.py b/netbox/dcim/forms/object_import.py index 3f2cc3ef6..a0de2ad24 100644 --- a/netbox/dcim/forms/object_import.py +++ b/netbox/dcim/forms/object_import.py @@ -113,31 +113,11 @@ class FrontPortTemplateImportForm(forms.ModelForm): label=_('Type'), choices=PortTypeChoices.CHOICES ) - rear_port = forms.ModelChoiceField( - label=_('Rear port'), - queryset=RearPortTemplate.objects.all(), - to_field_name='name' - ) - - def clean_device_type(self): - if device_type := self.cleaned_data['device_type']: - rear_port = self.fields['rear_port'] - rear_port.queryset = rear_port.queryset.filter(device_type=device_type) - - return device_type - - def clean_module_type(self): - if module_type := self.cleaned_data['module_type']: - rear_port = self.fields['rear_port'] - rear_port.queryset = rear_port.queryset.filter(module_type=module_type) - - return module_type class Meta: model = FrontPortTemplate fields = [ - 'device_type', 'module_type', 'name', 'type', 'color', 'rear_port', 'rear_port_position', 'label', - 'description', + 'device_type', 'module_type', 'name', 'type', 'color', 'positions', 'label', 'description', ] diff --git a/netbox/dcim/migrations/0221_m2m_port_assignments.py b/netbox/dcim/migrations/0221_m2m_port_assignments.py index b925959d8..d9030cd4e 100644 --- a/netbox/dcim/migrations/0221_m2m_port_assignments.py +++ b/netbox/dcim/migrations/0221_m2m_port_assignments.py @@ -6,12 +6,34 @@ from itertools import islice def chunked(iterable, size): - """Yield successive chunks of a given size from an iterator.""" + """ + Yield successive chunks of a given size from an iterator. + """ iterator = iter(iterable) while chunk := list(islice(iterator, size)): yield chunk +def populate_port_template_assignments(apps, schema_editor): + FrontPortTemplate = apps.get_model('dcim', 'FrontPortTemplate') + PortAssignmentTemplate = apps.get_model('dcim', 'PortAssignmentTemplate') + + front_ports = FrontPortTemplate.objects.iterator(chunk_size=1000) + + def generate_copies(): + for front_port in front_ports: + yield PortAssignmentTemplate( + front_port_id=front_port.pk, + front_port_position=None, + rear_port_id=front_port.rear_port_id, + rear_port_position=front_port.rear_port_position, + ) + + # Bulk insert in streaming batches + for chunk in chunked(generate_copies(), 1000): + PortAssignmentTemplate.objects.bulk_create(chunk, batch_size=1000) + + def populate_port_assignments(apps, schema_editor): FrontPort = apps.get_model('dcim', 'FrontPort') PortAssignment = apps.get_model('dcim', 'PortAssignment') @@ -38,6 +60,68 @@ class Migration(migrations.Migration): ] operations = [ + # Create PortAssignmentTemplate model (for DeviceTypes) + migrations.CreateModel( + name='PortAssignmentTemplate', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ( + 'front_port_position', + models.PositiveSmallIntegerField( + blank=True, + null=True, + validators=[ + django.core.validators.MinValueValidator(1), + django.core.validators.MaxValueValidator(1024) + ] + ) + ), + ( + 'rear_port_position', + models.PositiveSmallIntegerField( + validators=[ + django.core.validators.MinValueValidator(1), + django.core.validators.MaxValueValidator(1024) + ] + ) + ), + ( + 'front_port', + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='dcim.frontporttemplate') + ), + ( + 'rear_port', + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='dcim.rearporttemplate') + ), + ], + ), + migrations.AddConstraint( + model_name='portassignmenttemplate', + constraint=models.UniqueConstraint( + fields=('front_port', 'front_port_position'), + name='dcim_portassignmenttemplate_unique_front_port_position' + ), + ), + migrations.AddConstraint( + model_name='portassignmenttemplate', + constraint=models.UniqueConstraint( + fields=('rear_port', 'rear_port_position'), + name='dcim_portassignmenttemplate_unique_rear_port_position' + ), + ), + + # Add rear_ports ManyToManyField on FrontPortTemplate + migrations.AddField( + model_name='frontporttemplate', + name='rear_ports', + field=models.ManyToManyField( + related_name='front_ports', + through='dcim.PortAssignmentTemplate', + to='dcim.rearporttemplate' + ), + ), + + # Create PortAssignment model (for Devices) migrations.CreateModel( name='PortAssignment', fields=[ @@ -66,22 +150,39 @@ class Migration(migrations.Migration): ('rear_port', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='dcim.rearport')), ], ), + migrations.AddConstraint( + model_name='portassignment', + constraint=models.UniqueConstraint( + fields=('front_port', 'front_port_position'), + name='dcim_portassignment_unique_front_port_position' + ), + ), + migrations.AddConstraint( + model_name='portassignment', + constraint=models.UniqueConstraint( + fields=('rear_port', 'rear_port_position'), + name='dcim_portassignment_unique_rear_port_position' + ), + ), + + # Add rear_ports ManyToManyField on FrontPort migrations.AddField( model_name='frontport', name='rear_ports', - field=models.ManyToManyField(related_name='front_ports', through='dcim.PortAssignment', to='dcim.rearport'), - ), - migrations.AddConstraint( - model_name='portassignment', - constraint=models.UniqueConstraint( - fields=('front_port', 'front_port_position'), name='dcim_portassignment_unique_front_port_position' + field=models.ManyToManyField( + related_name='front_ports', + through='dcim.PortAssignment', + to='dcim.rearport' ), ), - migrations.AddConstraint( - model_name='portassignment', - constraint=models.UniqueConstraint( - fields=('rear_port', 'rear_port_position'), name='dcim_portassignment_unique_rear_port_position' - ), + + # Data migration + migrations.RunPython( + code=populate_port_template_assignments, + reverse_code=migrations.RunPython.noop + ), + migrations.RunPython( + code=populate_port_assignments, + reverse_code=migrations.RunPython.noop ), - migrations.RunPython(code=populate_port_assignments, reverse_code=migrations.RunPython.noop), ] diff --git a/netbox/dcim/migrations/0222_frontport_positions.py b/netbox/dcim/migrations/0222_frontport_positions.py index 93421ee47..a336968d5 100644 --- a/netbox/dcim/migrations/0222_frontport_positions.py +++ b/netbox/dcim/migrations/0222_frontport_positions.py @@ -9,6 +9,34 @@ class Migration(migrations.Migration): ] operations = [ + # Remove rear_port & rear_port_position from FrontPortTemplate + migrations.RemoveConstraint( + model_name='frontporttemplate', + name='dcim_frontporttemplate_unique_rear_port_position', + ), + migrations.RemoveField( + model_name='frontporttemplate', + name='rear_port', + ), + migrations.RemoveField( + model_name='frontporttemplate', + name='rear_port_position', + ), + + # Add positions on FrontPortTemplate + migrations.AddField( + model_name='frontporttemplate', + name='positions', + field=models.PositiveSmallIntegerField( + default=1, + validators=[ + django.core.validators.MinValueValidator(1), + django.core.validators.MaxValueValidator(1024) + ] + ), + ), + + # Remove rear_port & rear_port_position from FrontPort migrations.RemoveConstraint( model_name='frontport', name='dcim_frontport_unique_rear_port_position', @@ -21,6 +49,8 @@ class Migration(migrations.Migration): model_name='frontport', name='rear_port_position', ), + + # Add positions on FrontPort migrations.AddField( model_name='frontport', name='positions', diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index 177da1765..d9f70ee25 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -518,6 +518,69 @@ class InterfaceTemplate(InterfaceValidationMixin, ModularComponentTemplateModel) } +class PortAssignmentTemplate(models.Model): + """ + Maps a FrontPortTemplate & position to a RearPortTemplate & position. + """ + front_port = models.ForeignKey( + to='dcim.FrontPortTemplate', + on_delete=models.CASCADE, + ) + front_port_position = models.PositiveSmallIntegerField( + blank=True, + null=True, + validators=( + MinValueValidator(PORT_POSITION_MIN), + MaxValueValidator(PORT_POSITION_MAX), + ), + ) + rear_port = models.ForeignKey( + to='dcim.RearPortTemplate', + on_delete=models.CASCADE, + ) + rear_port_position = models.PositiveSmallIntegerField( + validators=( + MinValueValidator(PORT_POSITION_MIN), + MaxValueValidator(PORT_POSITION_MAX), + ), + ) + + class Meta: + 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): + + # 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 + ) + }) + + # 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 FrontPortTemplate(ModularComponentTemplateModel): """ Template for a pass-through port on the front of a new Device. @@ -531,18 +594,18 @@ 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(PORT_POSITION_MIN), MaxValueValidator(PORT_POSITION_MAX) - ] + ], + ) + rear_ports = models.ManyToManyField( + to='dcim.RearPortTemplate', + through='dcim.PortAssignmentTemplate', + related_name='front_ports', ) component_model = FrontPort @@ -557,51 +620,17 @@ 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') - 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 - 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 +640,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, } @@ -637,7 +665,7 @@ class RearPortTemplate(ModularComponentTemplateModel): validators=[ MinValueValidator(PORT_POSITION_MIN), MaxValueValidator(PORT_POSITION_MAX) - ] + ], ) component_model = RearPort