Replicate front/rear port assignments from DeviceType
Some checks are pending
CI / build (20.x, 3.12) (push) Waiting to run
CI / build (20.x, 3.13) (push) Waiting to run

This commit is contained in:
Jeremy Stretch
2025-11-20 16:42:54 -05:00
parent 1e0748e618
commit e71e4ef0ce
6 changed files with 145 additions and 108 deletions

View File

@@ -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')

View File

@@ -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)

View File

@@ -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

View File

@@ -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]
}

View File

@@ -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)

View File

@@ -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()