22752 - Restore rear port fields on front port bulk import (#22776)

This commit is contained in:
Arthur Hanson 2026-07-28 07:02:41 -07:00 committed by GitHub
parent eaa2816964
commit 7472c5d067
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 136 additions and 5 deletions

View File

@ -8,6 +8,7 @@ from django.utils.translation import gettext_lazy as _
from dcim.choices import *
from dcim.constants import *
from dcim.models import *
from dcim.utils import reconcile_port_mappings
from extras.models import ConfigTemplate
from ipam.choices import VLANQinQRoleChoices
from ipam.models import VLAN, VRF, IPAddress, VLANGroup
@ -1118,13 +1119,100 @@ class FrontPortImportForm(OwnerCSVMixin, NetBoxModelImportForm):
choices=PortTypeChoices,
help_text=_('Physical medium classification')
)
rear_port = CSVModelChoiceField(
label=_('Rear port'),
queryset=RearPort.objects.all(),
to_field_name='name',
help_text=_('Corresponding rear port (mapped to the front port\'s first position)')
)
rear_port_position = forms.IntegerField(
label=_('Rear port position'),
required=False,
help_text=_('Mapped position on the corresponding rear port (defaults to 1)')
)
class Meta:
model = FrontPort
fields = (
'device', 'name', 'label', 'type', 'color', 'mark_connected', 'positions', 'description', 'owner', 'tags'
'device', 'name', 'label', 'type', 'color', 'mark_connected', 'positions', 'rear_port',
'rear_port_position', 'description', 'owner', 'tags'
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Limit RearPort choices to those belonging to this device (or VC master)
if self.is_bound and 'device' in self.data:
try:
device = self.fields['device'].to_python(self.data['device'])
except forms.ValidationError:
device = None
else:
try:
device = self.instance.device
except Device.DoesNotExist:
device = None
if device:
self.fields['rear_port'].queryset = RearPort.objects.filter(
device__in=[device, device.get_vc_master()]
)
else:
self.fields['rear_port'].queryset = RearPort.objects.none()
def clean(self):
super().clean()
rear_port = self.cleaned_data.get('rear_port')
rear_port_position = self.cleaned_data.get('rear_port_position') or 1
if not rear_port:
return
# Validate the rear port position against the selected rear port
if rear_port_position > rear_port.positions:
raise forms.ValidationError({
'rear_port_position': _(
"Invalid rear port position ({rear_port_position}): Rear port {name} has only {positions} "
"positions."
).format(
rear_port_position=rear_port_position,
name=rear_port.name,
positions=rear_port.positions
)
})
# Ensure the target rear port position isn't already occupied. reconcile_port_mappings() creates the
# mapping via create() (bypassing validate_unique()), so without this check a collision would surface
# as an uncaught IntegrityError (HTTP 500) rather than a row-level validation error.
occupied = PortMapping.objects.filter(
rear_port=rear_port, rear_port_position=rear_port_position
).exclude(front_port=self.instance.pk)
if occupied.exists():
raise forms.ValidationError({
'rear_port_position': _(
"Rear port {name} position {rear_port_position} is already occupied."
).format(
name=rear_port.name,
rear_port_position=rear_port_position
)
})
def _save_m2m(self):
super()._save_m2m()
# Map the front port's first position to the specified rear port & position
if rear_port := self.cleaned_data.get('rear_port'):
reconcile_port_mappings(
PortMapping,
parent_field='front_port',
parent=self.instance,
desired=[{
'front_port_position': 1,
'rear_port_id': rear_port.pk,
'rear_port_position': self.cleaned_data.get('rear_port_position') or 1,
}],
)
class RearPortImportForm(OwnerCSVMixin, NetBoxModelImportForm):
device = CSVModelChoiceField(

View File

@ -3522,10 +3522,10 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
}
cls.csv_data = (
"device,name,type,positions",
"Device 1,Front Port 4,8p8c,1",
"Device 1,Front Port 5,8p8c,1",
"Device 1,Front Port 6,8p8c,1",
"device,name,type,positions,rear_port,rear_port_position",
"Device 1,Front Port 4,8p8c,1,Rear Port 4,1",
"Device 1,Front Port 5,8p8c,1,Rear Port 5,1",
"Device 1,Front Port 6,8p8c,1,Rear Port 6,1",
)
cls.csv_update_data = (
@ -3535,6 +3535,49 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
f"{front_ports[2].pk},Front Port 9,New description9",
)
def test_bulk_import_objects_with_permission(self):
# Importing front ports with a rear_port (and position) should create the corresponding PortMapping
def check_port_mappings(scenario_name):
front_port = FrontPort.objects.get(name='Front Port 4')
mapping = PortMapping.objects.get(front_port=front_port)
self.assertEqual(mapping.rear_port.name, 'Rear Port 4')
self.assertEqual(mapping.front_port_position, 1)
self.assertEqual(mapping.rear_port_position, 1)
super().test_bulk_import_objects_with_permission(post_import_callback=check_port_mappings)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[])
def test_bulk_import_rear_port_position_exceeds_capacity(self):
# A rear_port_position beyond the rear port's capacity is rejected without creating the front port
self.add_permissions('dcim.add_frontport')
csv_data = (
"device,name,type,positions,rear_port,rear_port_position",
"Device 1,Front Port 10,8p8c,1,Rear Port 4,2",
)
response = self.client.post(self._get_url('bulk_import'), {
'data': '\n'.join(csv_data),
'format': ImportFormatChoices.CSV,
'csv_delimiter': CSVDelimiterChoices.AUTO,
})
self.assertEqual(response.status_code, 200)
self.assertFalse(FrontPort.objects.filter(name='Front Port 10').exists())
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[])
def test_bulk_import_rear_port_position_occupied(self):
# An already-occupied rear port position is rejected (rather than raising an IntegrityError)
self.add_permissions('dcim.add_frontport')
csv_data = (
"device,name,type,positions,rear_port,rear_port_position",
"Device 1,Front Port 10,8p8c,1,Rear Port 1,1",
)
response = self.client.post(self._get_url('bulk_import'), {
'data': '\n'.join(csv_data),
'format': ImportFormatChoices.CSV,
'csv_delimiter': CSVDelimiterChoices.AUTO,
})
self.assertEqual(response.status_code, 200)
self.assertFalse(FrontPort.objects.filter(name='Front Port 10').exists())
def test_trace(self):
self.add_permissions(
'dcim.view_frontport',