Преглед изворни кода

Closes #19840 - Enable Site Filtering for Devices in Cable Bulk Import (#19923)

* feat(dcim): Add site fields to Cable bulk import form

Introduces `side_a_site` and `side_b_site` fields for the Cable bulk
import form. Limits device choices on both sides to the selected site
for improved input validation and consistency.

* feat(dcim): Enhance test data setup with multiple sites

Refactors tests to create multiple sites and assign devices accordingly.
Updates CSV data to include `side_a_site` and `side_b_site` fields for
scenarios involving multiple sites. This improves test coverage and
alignment with real-world use cases.

* docs(dcim): Update comments explaining indent for CSV import

Improved the inline comments to clarify the rationale behind allowing
devices with duplicate names on different sites during CSV bulk import.
Martin Hauser пре 8 месеци
родитељ
комит
14c4aeca54
2 измењених фајлова са 58 додато и 13 уклоњено
  1. 35 2
      netbox/dcim/forms/bulk_import.py
  2. 23 11
      netbox/dcim/tests/test_views.py

+ 35 - 2
netbox/dcim/forms/bulk_import.py

@@ -1335,6 +1335,13 @@ class MACAddressImportForm(NetBoxModelImportForm):
 
 class CableImportForm(NetBoxModelImportForm):
     # Termination A
+    side_a_site = CSVModelChoiceField(
+        label=_('Side A site'),
+        queryset=Site.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text=_('Site of parent device A (if any)'),
+    )
     side_a_device = CSVModelChoiceField(
         label=_('Side A device'),
         queryset=Device.objects.all(),
@@ -1353,6 +1360,13 @@ class CableImportForm(NetBoxModelImportForm):
     )
 
     # Termination B
+    side_b_site = CSVModelChoiceField(
+        label=_('Side B site'),
+        queryset=Site.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text=_('Site of parent device B (if any)'),
+    )
     side_b_device = CSVModelChoiceField(
         label=_('Side B device'),
         queryset=Device.objects.all(),
@@ -1400,10 +1414,29 @@ 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',
-            'status', 'tenant', 'label', 'color', 'length', 'length_unit', 'description', 'comments', 'tags',
+            'side_a_site', 'side_a_device', 'side_a_type', 'side_a_name', 'side_b_site', 'side_b_device', 'side_b_type',
+            'side_b_name', 'type', 'status', 'tenant', 'label', 'color', 'length', 'length_unit', 'description',
+            'comments', 'tags',
         ]
 
+    def __init__(self, data=None, *args, **kwargs):
+        super().__init__(data, *args, **kwargs)
+
+        if data:
+            # Limit choices for side_a_device to the assigned side_a_site
+            if side_a_site := data.get('side_a_site'):
+                side_a_device_params = {f'site__{self.fields["side_a_site"].to_field_name}': side_a_site}
+                self.fields['side_a_device'].queryset = self.fields['side_a_device'].queryset.filter(
+                    **side_a_device_params
+                )
+
+            # Limit choices for side_b_device to the assigned side_b_site
+            if side_b_site := data.get('side_b_site'):
+                side_b_device_params = {f'site__{self.fields["side_b_site"].to_field_name}': side_b_site}
+                self.fields['side_b_device'].queryset = self.fields['side_b_device'].queryset.filter(
+                    **side_b_device_params
+                )
+
     def _clean_side(self, side):
         """
         Derive a Cable's A/B termination objects.

+ 23 - 11
netbox/dcim/tests/test_views.py

@@ -3266,17 +3266,27 @@ class CableTestCase(
     @classmethod
     def setUpTestData(cls):
 
-        site = Site.objects.create(name='Site 1', slug='site-1')
+        sites = (
+            Site(name='Site 1', slug='site-1'),
+            Site(name='Site 2', slug='site-2'),
+        )
+        Site.objects.bulk_create(sites)
         manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
         devicetype = DeviceType.objects.create(model='Device Type 1', manufacturer=manufacturer)
         role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
         vc = VirtualChassis.objects.create(name='Virtual Chassis')
 
+        # NOTE: By design, NetBox now allows for the creation of devices with the same name if they belong to
+        # different sites.
+        # The CSV test below demonstrates that devices with identical names on different sites can be created
+        # and referenced successfully.
         devices = (
-            Device(name='Device 1', site=site, device_type=devicetype, role=role),
-            Device(name='Device 2', site=site, device_type=devicetype, role=role),
-            Device(name='Device 3', site=site, device_type=devicetype, role=role),
-            Device(name='Device 4', site=site, device_type=devicetype, role=role),
+            # Create 'Device 1' assigned to 'Site 1'
+            Device(name='Device 1', site=sites[0], device_type=devicetype, role=role),
+            Device(name='Device 2', site=sites[0], device_type=devicetype, role=role),
+            Device(name='Device 3', site=sites[0], device_type=devicetype, role=role),
+            # Create 'Device 1' assigned to 'Site 2' (allowed since the site is different)
+            Device(name='Device 1', site=sites[1], device_type=devicetype, role=role),
         )
         Device.objects.bulk_create(devices)
 
@@ -3327,13 +3337,15 @@ class CableTestCase(
             'tags': [t.pk for t in tags],
         }
 
+        # Ensure that CSV bulk import supports assigning terminations from parent devices that share
+        # the same device name, provided those devices belong to different sites.
         cls.csv_data = (
-            "side_a_device,side_a_type,side_a_name,side_b_device,side_b_type,side_b_name",
-            "Device 3,dcim.interface,Interface 1,Device 4,dcim.interface,Interface 1",
-            "Device 3,dcim.interface,Interface 2,Device 4,dcim.interface,Interface 2",
-            "Device 3,dcim.interface,Interface 3,Device 4,dcim.interface,Interface 3",
-            "Device 1,dcim.interface,Device 2 Interface,Device 4,dcim.interface,Interface 4",
-            "Device 1,dcim.interface,Device 3 Interface,Device 4,dcim.interface,Interface 5",
+            "side_a_site,side_a_device,side_a_type,side_a_name,side_b_site,side_b_device,side_b_type,side_b_name",
+            "Site 1,Device 3,dcim.interface,Interface 1,Site 2,Device 1,dcim.interface,Interface 1",
+            "Site 1,Device 3,dcim.interface,Interface 2,Site 2,Device 1,dcim.interface,Interface 2",
+            "Site 1,Device 3,dcim.interface,Interface 3,Site 2,Device 1,dcim.interface,Interface 3",
+            "Site 1,Device 1,dcim.interface,Device 2 Interface,Site 2,Device 1,dcim.interface,Interface 4",
+            "Site 1,Device 1,dcim.interface,Device 3 Interface,Site 2,Device 1,dcim.interface,Interface 5",
         )
 
         cls.csv_update_data = (