From 2721455082577ce37788be11b543940190e06d41 Mon Sep 17 00:00:00 2001 From: Jonathan Senecal Date: Tue, 12 Nov 2024 20:22:56 +0000 Subject: [PATCH 1/3] Fixes #17954 - Handle CircuitTerminations in Cable Bulk Import --- netbox/dcim/forms/bulk_import.py | 57 ++++++++++++++++++++--- netbox/templates/generic/bulk_import.html | 2 + netbox/utilities/forms/fields/csv.py | 5 ++ 3 files changed, 57 insertions(+), 7 deletions(-) diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index 58ae350913f..9794231f21c 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -8,6 +8,7 @@ from dcim.choices import * from dcim.constants import * from dcim.models import * +from circuits.models import Circuit from extras.models import ConfigTemplate from ipam.models import VRF, IPAddress from netbox.forms import NetBoxModelImportForm @@ -1171,8 +1172,18 @@ class CableImportForm(NetBoxModelImportForm): label=_('Side A device'), queryset=Device.objects.all(), to_field_name='name', + required=False, + conditional=True, help_text=_('Device name') ) + side_a_circuit = CSVModelChoiceField( + label=_('Side A circuit'), + queryset=Circuit.objects.all(), + to_field_name='cid', + required=False, + conditional=True, + help_text=_('Circuit ID'), + ) side_a_type = CSVContentTypeField( label=_('Side A type'), queryset=ContentType.objects.all(), @@ -1189,8 +1200,18 @@ class CableImportForm(NetBoxModelImportForm): label=_('Side B device'), queryset=Device.objects.all(), to_field_name='name', + required=False, + conditional=True, help_text=_('Device name') ) + side_b_circuit = CSVModelChoiceField( + label=_('Side A device'), + queryset=Circuit.objects.all(), + to_field_name='cid', + required=False, + conditional=True, + help_text=_('Circuit ID'), + ) side_b_type = CSVContentTypeField( label=_('Side B type'), queryset=ContentType.objects.all(), @@ -1232,7 +1253,7 @@ class CableImportForm(NetBoxModelImportForm): class Meta: model = Cable fields = [ - 'side_a_device', 'side_a_type', 'side_a_name', 'side_b_device', 'side_b_type', 'side_b_name', 'type', + 'side_a_device', 'side_a_circuit', 'side_a_type', 'side_a_name', 'side_b_device', 'side_b_circuit', 'side_b_type', 'side_b_name', 'type', 'status', 'tenant', 'label', 'color', 'length', 'length_unit', 'description', 'comments', 'tags', ] @@ -1245,18 +1266,40 @@ def _clean_side(self, side): assert side in 'ab', f"Invalid side designation: {side}" device = self.cleaned_data.get(f'side_{side}_device') + circuit = self.cleaned_data.get(f'side_{side}_circuit') content_type = self.cleaned_data.get(f'side_{side}_type') name = self.cleaned_data.get(f'side_{side}_name') - if not device or not content_type or not name: + + if not (device or circuit) or not content_type or not name: return None + + if device and circuit: + raise forms.ValidationError( + _("Side {side_upper}: Both `device` and `circuit` cannot be specified at the same time").format(side_upper=side.upper()) + ) model = content_type.model_class() try: - if device.virtual_chassis and device.virtual_chassis.master == device and \ - model.objects.filter(device=device, name=name).count() == 0: - termination_object = model.objects.get(device__in=device.virtual_chassis.members.all(), name=name) - else: - termination_object = model.objects.get(device=device, name=name) + + # Should never happen as we return None above if we don't have a device or circuit + assert device or circuit + + if device: + if device.virtual_chassis and device.virtual_chassis.master == device and \ + model.objects.filter(device=device, name=name).count() == 0: + termination_object = model.objects.get(device__in=device.virtual_chassis.members.all(), name=name) + else: + termination_object = model.objects.get(device=device, name=name) + self.fields[f'side_{side}_circuit'].required = False + elif circuit: + termination_object = model.objects.get(circuit=circuit, term_side=name.upper()) + if termination_object.provider_network is not None: + raise forms.ValidationError( + _("Side {side_upper}: {circuit} {termination_object} is already connected to a Provider Network").format( + side_upper=side.upper(), circuit=circuit, termination_object=termination_object + ) + ) + if termination_object.cable is not None and termination_object.cable != self.instance: raise forms.ValidationError( _("Side {side_upper}: {device} {termination_object} is already connected").format( diff --git a/netbox/templates/generic/bulk_import.html b/netbox/templates/generic/bulk_import.html index 3a652d3e935..a08b5a2996a 100644 --- a/netbox/templates/generic/bulk_import.html +++ b/netbox/templates/generic/bulk_import.html @@ -129,6 +129,8 @@

{% trans "Field Options" %}

{% if field.required %} {% checkmark True true="Required" %} + {% elif field.conditional %} + {% tag "Conditional" %} {% else %} {{ ''|placeholder }} {% endif %} diff --git a/netbox/utilities/forms/fields/csv.py b/netbox/utilities/forms/fields/csv.py index a2d4025cbc5..76b3d887cba 100644 --- a/netbox/utilities/forms/fields/csv.py +++ b/netbox/utilities/forms/fields/csv.py @@ -57,6 +57,11 @@ class CSVModelChoiceField(forms.ModelChoiceField): 'invalid_choice': _('Object not found: %(value)s'), } + def __init__(self, conditional=False, *args, **kwargs): + # Used to trigger conditional validation in the forms + self.conditional = conditional + super().__init__(*args, **kwargs) + def to_python(self, value): try: return super().to_python(value) From ea34d7bce88e4f74743585b99c9e4058517b7400 Mon Sep 17 00:00:00 2001 From: Jonathan Senecal Date: Wed, 13 Nov 2024 13:59:32 +0000 Subject: [PATCH 2/3] Translate badge text and simplify implementation --- netbox/templates/generic/bulk_import.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/templates/generic/bulk_import.html b/netbox/templates/generic/bulk_import.html index a08b5a2996a..82c1299101f 100644 --- a/netbox/templates/generic/bulk_import.html +++ b/netbox/templates/generic/bulk_import.html @@ -130,7 +130,7 @@

{% trans "Field Options" %}

{% if field.required %} {% checkmark True true="Required" %} {% elif field.conditional %} - {% tag "Conditional" %} + {% trans "Conditional" %} {% else %} {{ ''|placeholder }} {% endif %} From 8660838b36b85fe0e332650b937a059ffe1d29b7 Mon Sep 17 00:00:00 2001 From: Jonathan Senecal Date: Wed, 13 Nov 2024 14:02:45 +0000 Subject: [PATCH 3/3] Update erroneous comment --- netbox/utilities/forms/fields/csv.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/utilities/forms/fields/csv.py b/netbox/utilities/forms/fields/csv.py index 76b3d887cba..14692f0d895 100644 --- a/netbox/utilities/forms/fields/csv.py +++ b/netbox/utilities/forms/fields/csv.py @@ -58,7 +58,7 @@ class CSVModelChoiceField(forms.ModelChoiceField): } def __init__(self, conditional=False, *args, **kwargs): - # Used to trigger conditional validation in the forms + # Used to display tags for fields that are conditionally required self.conditional = conditional super().__init__(*args, **kwargs)