|
@@ -8,6 +8,7 @@ from django.utils.translation import gettext_lazy as _
|
|
|
from dcim.choices import *
|
|
from dcim.choices import *
|
|
|
from dcim.constants import *
|
|
from dcim.constants import *
|
|
|
from dcim.models import *
|
|
from dcim.models import *
|
|
|
|
|
+from dcim.utils import reconcile_port_mappings
|
|
|
from extras.models import ConfigTemplate
|
|
from extras.models import ConfigTemplate
|
|
|
from ipam.choices import VLANQinQRoleChoices
|
|
from ipam.choices import VLANQinQRoleChoices
|
|
|
from ipam.models import VLAN, VRF, IPAddress, VLANGroup
|
|
from ipam.models import VLAN, VRF, IPAddress, VLANGroup
|
|
@@ -1118,13 +1119,100 @@ class FrontPortImportForm(OwnerCSVMixin, NetBoxModelImportForm):
|
|
|
choices=PortTypeChoices,
|
|
choices=PortTypeChoices,
|
|
|
help_text=_('Physical medium classification')
|
|
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:
|
|
class Meta:
|
|
|
model = FrontPort
|
|
model = FrontPort
|
|
|
fields = (
|
|
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):
|
|
class RearPortImportForm(OwnerCSVMixin, NetBoxModelImportForm):
|
|
|
device = CSVModelChoiceField(
|
|
device = CSVModelChoiceField(
|