Jeremy Stretch пре 2 недеља
родитељ
комит
4b5fc1a260

+ 3 - 1
docs/getting-started/populating-data.md

@@ -26,7 +26,9 @@ When viewing the CSV import form for an object type, you'll notice that the head
 
 <!-- TODO: Screenshot -->
 
-If an "id" field is added the data will be used to update existing records instead of importing new objects.
+If an "id" field is added the data will be used to update existing records instead of importing new objects. When updating, only the columns present in the data are applied; all others are left unchanged. Note that some columns are interdependent: for example, updating a cable's terminations requires that the columns identifying their type and parent object be included as well.
+
+Some columns accept multiple values, separated by commas. Because the comma also serves as the CSV field delimiter, such a value must be enclosed in double quotes, e.g. `"tag1,tag2,tag3"`. (When importing JSON- or YAML-formatted data, these columns accept a native list instead.) An object whose name itself contains a comma cannot be referenced by a multi-value column, as there is no way to distinguish it from a separator.
 
 Note that some models (namely device types and module types) do not support CSV import. Instead, they accept YAML-formatted data to facilitate the import of both the parent object as well as child components.
 

+ 3 - 1
docs/models/dcim/cable.md

@@ -34,7 +34,9 @@ The profile to which the cable conforms. The profile determines the mapping of t
 
 A single-position cable is allowed only one termination point at each end. There is no limit to the number of terminations a multi-position cable may have. Each end of a cable must have the same number of terminations, unless connected to a pass-through port or to a circuit termination.
 
-The assignment of a cable profile is optional. If no profile is assigned, legacy tracing behavior will be preserved.
+The assignment of a cable profile is optional. If no profile is assigned, legacy tracing behavior will be preserved. Note that a cable's profile is what maps each termination to a connector and position: a cable carrying multiple terminations on an end but having no profile assigned is permitted, but NetBox cannot map its positions across the cable. Assign a profile to model a breakout cable whose individual positions must be traced.
+
+When creating cables in bulk, each side accepts a comma-separated list of termination names, along with either a single parent device (or power panel) shared by all of them or one parent per name. Terminations are assigned to connectors in the order given, so the order of these lists determines how the cable is wired.
 
 ### Type
 

+ 102 - 10
netbox/dcim/forms/bulk_import.py

@@ -2,8 +2,9 @@ from django import forms
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.postgres.forms.array import SimpleArrayField
 from django.core.exceptions import MultipleObjectsReturned, ObjectDoesNotExist
+from django.utils.functional import lazy
 from django.utils.html import format_html
-from django.utils.safestring import mark_safe
+from django.utils.safestring import SafeString, mark_safe
 from django.utils.translation import gettext_lazy as _
 
 from dcim.choices import *
@@ -78,6 +79,10 @@ __all__ = (
     'VirtualDeviceContextImportForm'
 )
 
+# A lazily evaluated format_html(), for help text which must not resolve its translated content until
+# the field is rendered. Unlike mark_safe(), this escapes the interpolated arguments.
+format_html_lazy = lazy(format_html, SafeString)
+
 
 class RegionImportForm(NestedGroupModelImportForm):
     parent = CSVModelChoiceField(
@@ -1702,20 +1707,32 @@ class CableBundleImportForm(PrimaryModelImportForm):
 
 
 class CableImportForm(PrimaryModelImportForm):
+    # Cable.clean() reports termination errors (e.g. cable profile violations) against the model's
+    # a_terminations/b_terminations attributes, which have no corresponding fields on this form.
+    # Map them onto the columns which define each side's terminations.
+    TERMINATION_ERROR_FIELDS = {
+        'a_terminations': 'side_a_name',
+        'b_terminations': 'side_b_name',
+    }
+
+    # Columns which take effect only by resolving a side's terminations, and are therefore
+    # meaningless without that side's name column.
+    TERMINATION_DEPENDENT_COLUMNS = ('site', 'device', 'power_panel', 'type')
+
     # 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)'),
+        help_text=_('Site of parent device A (if any). Restricts the devices & power panels which may be matched.'),
     )
     side_a_device = CSVModelMultipleChoiceField(
         label=_('Side A device'),
         queryset=Device.objects.all(),
         required=False,
         to_field_name='name',
-        help_text=format_html(
+        help_text=format_html_lazy(
             '{} <code>{}</code>',
             _('Device name(s) for device component terminations. Separate multiple values with commas, '
               'encased with double quotes. Example:'),
@@ -1727,7 +1744,7 @@ class CableImportForm(PrimaryModelImportForm):
         queryset=PowerPanel.objects.all(),
         required=False,
         to_field_name='name',
-        help_text=format_html(
+        help_text=format_html_lazy(
             '{} <code>{}</code>',
             _('Power panel name(s) for power feed terminations. Separate multiple values with commas, '
               'encased with double quotes. Example:'),
@@ -1742,7 +1759,7 @@ class CableImportForm(PrimaryModelImportForm):
     )
     side_a_name = forms.CharField(
         label=_('Side A name'),
-        help_text=format_html(
+        help_text=format_html_lazy(
             '{} <code>{}</code>',
             _('Termination name(s). Separate multiple values with commas, encased with double quotes. '
               'Example:'),
@@ -1756,14 +1773,14 @@ class CableImportForm(PrimaryModelImportForm):
         queryset=Site.objects.all(),
         required=False,
         to_field_name='name',
-        help_text=_('Site of parent device B (if any)'),
+        help_text=_('Site of parent device B (if any). Restricts the devices & power panels which may be matched.'),
     )
     side_b_device = CSVModelMultipleChoiceField(
         label=_('Side B device'),
         queryset=Device.objects.all(),
         required=False,
         to_field_name='name',
-        help_text=format_html(
+        help_text=format_html_lazy(
             '{} <code>{}</code>',
             _('Device name(s) for device component terminations. Separate multiple values with commas, '
               'encased with double quotes. Example:'),
@@ -1775,7 +1792,7 @@ class CableImportForm(PrimaryModelImportForm):
         queryset=PowerPanel.objects.all(),
         required=False,
         to_field_name='name',
-        help_text=format_html(
+        help_text=format_html_lazy(
             '{} <code>{}</code>',
             _('Power panel name(s) for power feed terminations. Separate multiple values with commas, '
               'encased with double quotes. Example:'),
@@ -1790,7 +1807,7 @@ class CableImportForm(PrimaryModelImportForm):
     )
     side_b_name = forms.CharField(
         label=_('Side B name'),
-        help_text=format_html(
+        help_text=format_html_lazy(
             '{} <code>{}</code>',
             _('Termination name(s). Separate multiple values with commas, encased with double quotes. '
               'Example:'),
@@ -1877,6 +1894,30 @@ class CableImportForm(PrimaryModelImportForm):
                     **side_b_parent_params
                 )
 
+    def add_error(self, field, error):
+        # Remap any termination errors raised by Cable.clean() onto the relevant import column.
+        # Without this, Django raises ValueError for an error reported against a field which does
+        # not exist on the form. Errors are dispatched per key so that both sides can fall back to
+        # a non-field error without colliding.
+        if field is None and hasattr(error, 'error_dict'):
+            for name, errors in error.error_dict.items():
+                super().add_error(self._map_termination_field(name), errors)
+            return
+
+        super().add_error(self._map_termination_field(field), error)
+
+    def _map_termination_field(self, field):
+        """
+        Return the import column against which a model-level termination error should be reported,
+        or None (i.e. a non-field error) if that column is not present on the form. Columns absent
+        from an update record are removed from the form by BulkImportView, so a cable profile
+        violation can be reported against a side whose terminations were not being modified.
+        """
+        if field not in self.TERMINATION_ERROR_FIELDS:
+            return field
+        mapped_field = self.TERMINATION_ERROR_FIELDS[field]
+        return mapped_field if mapped_field in self.fields else None
+
     @staticmethod
     def _split_side_values(value):
         """
@@ -1889,6 +1930,22 @@ class CableImportForm(PrimaryModelImportForm):
             value = str(value).split(',')
         return ['' if item is None else str(item).strip() for item in value]
 
+    def _check_companion_column(self, side, field_name):
+        """
+        Verify that a column needed to resolve a side's terminations is present on the form.
+
+        When updating an existing object, BulkImportView removes every field which does not appear
+        in the record. A record which redefines a side's termination names must therefore also
+        include the columns identifying their type and parent, otherwise the names cannot be
+        resolved and the update would appear to succeed while changing nothing.
+        """
+        if field_name not in self.fields:
+            raise forms.ValidationError(
+                _(
+                    "Side {side_upper}: The {column} column must be included when modifying terminations"
+                ).format(side_upper=side.upper(), column=field_name)
+            )
+
     def _resolve_side_parent_objects(self, field_name):
         """
         Resolve a side's parent objects from the raw submitted values, preserving their order.
@@ -1949,7 +2006,14 @@ class CableImportForm(PrimaryModelImportForm):
         if not isinstance(names, (list, tuple)):
             names = self.cleaned_data.get(f'side_{side}_name')
         names = self._split_side_values(names)
-        if not content_type or not names:
+        if not names:
+            return None
+
+        if not content_type:
+            # BulkImportView removes any field absent from an update record, so a missing termination
+            # type here means the column was omitted rather than left blank. Reject it: silently
+            # ignoring the submitted names would report a successful update which changed nothing.
+            self._check_companion_column(side, f'side_{side}_type')
             return None
 
         if '' in names:
@@ -1972,6 +2036,8 @@ class CableImportForm(PrimaryModelImportForm):
                 _("Bulk import does not support {type} terminations").format(type=content_type)
             )
 
+        self._check_companion_column(side, parent_field_name)
+
         parents = self._resolve_side_parent_objects(parent_field_name)
         if parents is None:
             # The parent field has already raised its own validation error
@@ -2045,6 +2111,32 @@ class CableImportForm(PrimaryModelImportForm):
             )
         return color_parsed
 
+    def clean(self):
+        cleaned_data = super().clean()
+
+        # Termination resolution is driven by clean_side_<x>_name(), which Django never calls for a
+        # field BulkImportView has removed. An update record which supplies a side's supporting
+        # columns but omits its name column would therefore have those columns silently discarded
+        # and report an update which changed nothing; reject it instead. This cannot trigger on
+        # creation, where the name columns are always present (and required).
+        for side in ('a', 'b'):
+            if f'side_{side}_name' in self.fields:
+                continue
+            if supplied := [
+                column for column in self.TERMINATION_DEPENDENT_COLUMNS
+                if f'side_{side}_{column}' in self.fields
+            ]:
+                self.add_error(None, _(
+                    "Side {side_upper}: The side_{side}_name column must be included when modifying "
+                    "terminations (found {columns})"
+                ).format(
+                    side_upper=side.upper(),
+                    side=side,
+                    columns=', '.join(f'side_{side}_{column}' for column in supplied),
+                ))
+
+        return cleaned_data
+
     def clean_side_a_name(self):
         return self._clean_side('a')
 

+ 33 - 9
netbox/dcim/models/cables.py

@@ -458,6 +458,24 @@ class Cable(PrimaryModel):
 
         return a_terminations, b_terminations
 
+    def _connectors_reassigned(self, existing, terminations):
+        """
+        Return True if any of the given terminating objects already terminates this Cable, but would be
+        assigned to a different connector than the one it currently occupies.
+
+        Args:
+            existing: Mapping of terminating objects to their current CableTerminations, as returned by
+                get_terminations()
+            terminations: The ordered list of terminating objects to be assigned to this end of the Cable
+        """
+        if not self.profile:
+            # Connectors are assigned only for a Cable which has a profile
+            return False
+        for connector, termination in enumerate(terminations, start=1):
+            if (ct := existing.get(termination)) and ct.connector != connector:
+                return True
+        return False
+
     def update_terminations(self, force=False):
         """
         Create/delete CableTerminations for this Cable to reflect its current state.
@@ -468,27 +486,33 @@ class Cable(PrimaryModel):
         """
         a_terminations, b_terminations = self.get_terminations()
 
+        # A CableTermination's connector is derived from its position within its end's list of terminating
+        # objects, so reordering that list (or removing an object from the middle of it) rewires the Cable
+        # without changing which objects it connects. Recreate the affected end's CableTerminations so that
+        # each is reassigned to its new connector.
+        force_a = force or self._connectors_reassigned(a_terminations, self.a_terminations)
+        force_b = force or self._connectors_reassigned(b_terminations, self.b_terminations)
+
         # When force-recreating terminations (e.g. after a profile change), cache the termination objects
         # from the database before deleting, so they are available for recreation. Without this, the
         # a_terminations/b_terminations properties would query the DB after deletion and return empty lists.
-        if force:
-            if not hasattr(self, '_a_terminations'):
-                self._a_terminations = list(a_terminations.keys())
-            if not hasattr(self, '_b_terminations'):
-                self._b_terminations = list(b_terminations.keys())
+        if force_a and not hasattr(self, '_a_terminations'):
+            self._a_terminations = list(a_terminations.keys())
+        if force_b and not hasattr(self, '_b_terminations'):
+            self._b_terminations = list(b_terminations.keys())
 
         # Delete any stale CableTerminations
         for termination, ct in a_terminations.items():
-            if force or (termination.pk and termination not in self.a_terminations):
+            if force_a or (termination.pk and termination not in self.a_terminations):
                 ct.delete()
         for termination, ct in b_terminations.items():
-            if force or (termination.pk and termination not in self.b_terminations):
+            if force_b or (termination.pk and termination not in self.b_terminations):
                 ct.delete()
 
         # Save any new CableTerminations
         profile = self.profile_class() if self.profile else None
         for i, termination in enumerate(self.a_terminations, start=1):
-            if force or not termination.pk or termination not in a_terminations:
+            if force_a or not termination.pk or termination not in a_terminations:
                 connector = positions = None
                 if profile:
                     connector = i
@@ -501,7 +525,7 @@ class Cable(PrimaryModel):
                     termination=termination
                 ).save()
         for i, termination in enumerate(self.b_terminations, start=1):
-            if force or not termination.pk or termination not in b_terminations:
+            if force_b or not termination.pk or termination not in b_terminations:
                 connector = positions = None
                 if profile:
                     connector = i

+ 14 - 4
netbox/dcim/tables/cables.py

@@ -27,22 +27,32 @@ class CableTerminationsColumn(tables.Column):
         self.attr = attr
         super().__init__(accessor=Accessor('terminations'), *args, **kwargs)
 
-    def _get_terminations(self, manager):
-        terminations = set()
+    def _get_terminations(self, manager, deduplicate=False):
+        # CableTerminations are ordered by connector, which defines the mapping between the two ends of
+        # a cable, so the order in which they are listed is significant: collecting them into a set would
+        # render (and export) them in an arbitrary order, which for a cable with a profile assigned no
+        # longer reflects how it is wired.
+        terminations = []
         for cabletermination in manager.all():
             if cabletermination.cable_end == self.cable_end:
                 if termination := getattr(cabletermination, self.attr, None):
-                    terminations.add(termination)
+                    if deduplicate and termination in terminations:
+                        continue
+                    terminations.append(termination)
 
         return terminations
 
     def render(self, value):
+        # Collapse any repeated parent objects (e.g. several terminations on the same device) for display
         links = [
-            f'<a href="{term.get_absolute_url()}">{escape(term)}</a>' for term in self._get_terminations(value)
+            f'<a href="{term.get_absolute_url()}">{escape(term)}</a>'
+            for term in self._get_terminations(value, deduplicate=True)
         ]
         return mark_safe('<br />'.join(links) or '&mdash;')
 
     def value(self, value):
+        # Exported values are never deduplicated: each termination must be accompanied by its parent
+        # object at the same position for the exported data to be re-importable.
         return ','.join([str(t) for t in self._get_terminations(value)])
 
 

+ 30 - 0
netbox/dcim/tests/test_forms.py

@@ -775,6 +775,36 @@ class CableTestCase(TestCase):
         self.assertFalse(form.is_valid())
         self.assertIn('Duplicate termination', str(form.errors.get('side_b_name')))
 
+    def test_import_terminations_exceeding_profile_capacity(self):
+        """A side carrying more terminations than its profile permits reports against that side's column."""
+        form = CableImportForm(data={
+            'side_a_device': 'Device A',
+            'side_a_type': 'dcim.interface',
+            'side_a_name': 'et-0/0/0',
+            'side_b_device': 'Device B',
+            'side_b_type': 'dcim.interface',
+            'side_b_name': 'et-0/0/0,et-0/0/1,et-0/0/2',
+            'status': LinkStatusChoices.STATUS_CONNECTED,
+            'profile': CableProfileChoices.BREAKOUT_1C2P_2C1P,
+        })
+        self.assertFalse(form.is_valid())
+        self.assertIn('only 2 are permitted', str(form.errors.get('side_b_name')))
+
+    def test_import_terminations_exceeding_profile_capacity_side_a(self):
+        """The same applies to side A, whose profile capacity is often lower than side B's."""
+        form = CableImportForm(data={
+            'side_a_device': 'Device A',
+            'side_a_type': 'dcim.interface',
+            'side_a_name': 'et-0/0/0,et-0/0/1',
+            'side_b_device': 'Device B',
+            'side_b_type': 'dcim.interface',
+            'side_b_name': 'et-0/0/0',
+            'status': LinkStatusChoices.STATUS_CONNECTED,
+            'profile': CableProfileChoices.BREAKOUT_1C2P_2C1P,
+        })
+        self.assertFalse(form.is_valid())
+        self.assertIn('only 1 are permitted', str(form.errors.get('side_a_name')))
+
     def test_import_multiple_terminations_empty_name(self):
         """A trailing comma produces an empty termination name and is rejected."""
         form = CableImportForm(data={

+ 91 - 0
netbox/dcim/tests/test_models.py

@@ -2353,6 +2353,97 @@ class CableTestCase(TestCase):
         self.assertEqual(a_terms, [interface1])
         self.assertEqual(b_terms, [interface2])
 
+    def _create_multiposition_cable(self, count=4):
+        """
+        Create a cable with `count` terminations at either end, using the 4C1P trunk profile. Returns
+        the cable and its A & B terminating objects.
+        """
+        device1 = Device.objects.get(name='TestDevice1')
+        device2 = Device.objects.get(name='TestDevice2')
+        a_interfaces = [
+            Interface.objects.create(device=device1, name=f'trunk-a{i}') for i in range(count)
+        ]
+        b_interfaces = [
+            Interface.objects.create(device=device2, name=f'trunk-b{i}') for i in range(count)
+        ]
+        cable = Cable(
+            a_terminations=a_interfaces,
+            b_terminations=b_interfaces,
+            profile=CableProfileChoices.TRUNK_4C1P,
+        )
+        cable.save()
+
+        return cable, a_interfaces, b_interfaces
+
+    def _get_connectors(self, cable, cable_end):
+        return [
+            (ct.connector, ct.termination) for ct in cable.terminations.filter(cable_end=cable_end)
+        ]
+
+    def test_reordering_terminations_reassigns_connectors(self):
+        """
+        A Cable's terminations are assigned to connectors in the order given, so reordering them must
+        rewire the Cable even though its set of terminating objects is unchanged.
+        """
+        cable, a_interfaces, b_interfaces = self._create_multiposition_cable()
+        self.assertEqual(
+            self._get_connectors(cable, 'B'), list(enumerate(b_interfaces, start=1))
+        )
+
+        # Reverse the B side terminations
+        cable = Cable.objects.get(pk=cable.pk)
+        cable.b_terminations = list(reversed(b_interfaces))
+        cable.save()
+        self.assertEqual(
+            self._get_connectors(cable, 'B'), list(enumerate(reversed(b_interfaces), start=1))
+        )
+
+        # The A side, which was not modified, must be left alone
+        self.assertEqual(
+            self._get_connectors(cable, 'A'), list(enumerate(a_interfaces, start=1))
+        )
+
+        # The reordering must be reflected in the terminations' link peers
+        self.assertEqual(
+            Interface.objects.get(pk=a_interfaces[0].pk).link_peers, [b_interfaces[-1]]
+        )
+
+    def test_removing_a_termination_reassigns_connectors(self):
+        """
+        Removing a termination from the middle of a Cable's list must renumber the connectors of those
+        which follow it.
+        """
+        cable, a_interfaces, b_interfaces = self._create_multiposition_cable()
+
+        cable = Cable.objects.get(pk=cable.pk)
+        cable.b_terminations = [b_interfaces[0], b_interfaces[2], b_interfaces[3]]
+        cable.save()
+        self.assertEqual(
+            self._get_connectors(cable, 'B'),
+            [(1, b_interfaces[0]), (2, b_interfaces[2]), (3, b_interfaces[3])]
+        )
+
+    def test_appending_a_termination_preserves_connectors(self):
+        """
+        Appending a termination must not disturb the connectors already assigned to the terminations
+        which precede it.
+        """
+        cable, a_interfaces, b_interfaces = self._create_multiposition_cable(count=3)
+        original_cts = {ct.termination: ct.pk for ct in cable.terminations.filter(cable_end='B')}
+        new_interface = Interface.objects.create(device=Device.objects.get(name='TestDevice2'), name='trunk-b3')
+
+        cable = Cable.objects.get(pk=cable.pk)
+        cable.b_terminations = [*b_interfaces, new_interface]
+        cable.save()
+        self.assertEqual(
+            self._get_connectors(cable, 'B'), list(enumerate([*b_interfaces, new_interface], start=1))
+        )
+
+        # The existing CableTerminations must not have been recreated
+        for ct in cable.terminations.filter(cable_end='B'):
+            if ct.termination in original_cts:
+                self.assertEqual(ct.pk, original_cts[ct.termination])
+
     @tag('regression')  # #21498
     def test_path_refreshes_replaced_cablepath_reference(self):
         """

+ 98 - 1
netbox/dcim/tests/test_tables.py

@@ -1,4 +1,15 @@
-from dcim.models import ConsolePort, Interface, PowerPort
+from dcim.choices import CableEndChoices, CableProfileChoices, InterfaceTypeChoices
+from dcim.models import (
+    Cable,
+    ConsolePort,
+    Device,
+    DeviceRole,
+    DeviceType,
+    Interface,
+    Manufacturer,
+    PowerPort,
+    Site,
+)
 from dcim.tables import *
 from utilities.testing import TableTestCases
 
@@ -178,6 +189,92 @@ class InterfaceConnectionTableTestCase(TableTestCases.StandardTableTestCase):
 class CableTableTestCase(TableTestCases.StandardTableTestCase):
     table = CableTable
 
+    @staticmethod
+    def _create_device(name):
+        site = Site.objects.get_or_create(name='Site 1', slug='site-1')[0]
+        manufacturer = Manufacturer.objects.get_or_create(name='Manufacturer 1', slug='manufacturer-1')[0]
+        device_type = DeviceType.objects.get_or_create(model='Device Type 1', manufacturer=manufacturer)[0]
+        role = DeviceRole.objects.get_or_create(name='Device Role 1', slug='device-role-1')[0]
+
+        return Device.objects.create(name=name, site=site, device_type=device_type, role=role)
+
+    def test_termination_columns_follow_connector_order(self):
+        """Termination & parent columns must render in connector order, not an arbitrary one."""
+        site = Site.objects.create(name='Site 1', slug='site-1')
+        manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
+        device_type = DeviceType.objects.create(model='Device Type 1', manufacturer=manufacturer)
+        role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
+
+        switch = Device.objects.create(name='switch1', site=site, device_type=device_type, role=role)
+        uplink = Interface.objects.create(
+            device=switch, name='et-0/0/0', type=InterfaceTypeChoices.TYPE_100GE_QSFP28
+        )
+        # Create the servers in ascending order, then cable them in descending order, so that
+        # connector order and primary key order disagree.
+        servers = [
+            Device.objects.create(name=f'server{i}', site=site, device_type=device_type, role=role)
+            for i in range(1, 5)
+        ]
+        interfaces = [
+            Interface.objects.create(device=device, name='eth0', type=InterfaceTypeChoices.TYPE_25GE_SFP28)
+            for device in servers
+        ]
+        cable = Cable(
+            a_terminations=[uplink],
+            b_terminations=list(reversed(interfaces)),
+            profile=CableProfileChoices.BREAKOUT_1C4P_4C1P,
+        )
+        cable.save()
+
+        table = CableTable(Cable.objects.filter(pk=cable.pk))
+        table.columns.show('device_b')
+        row = list(table.rows)[0]
+
+        self.assertEqual(row.get_cell_value('device_b'), 'server4,server3,server2,server1')
+        self.assertEqual(
+            [ct.termination for ct in cable.terminations.filter(cable_end=CableEndChoices.SIDE_B)],
+            list(reversed(interfaces))
+        )
+
+    def test_parent_columns_are_not_deduplicated_for_export(self):
+        """
+        A parent column must export one value per termination so that the exported data can be
+        re-imported, but collapse repeated values when rendered.
+        """
+        switch = self._create_device('switch1')
+        servers = [self._create_device(f'server{i}') for i in range(1, 3)]
+        uplinks = [
+            Interface.objects.create(
+                device=switch, name=f'et-0/0/{i}', type=InterfaceTypeChoices.TYPE_100GE_QSFP28
+            )
+            for i in range(4)
+        ]
+        interfaces = [
+            Interface.objects.create(device=device, name=name, type=InterfaceTypeChoices.TYPE_25GE_SFP28)
+            for device in servers for name in ('eth0', 'eth1')
+        ]
+        cable = Cable(
+            a_terminations=uplinks,
+            b_terminations=interfaces,
+            profile=CableProfileChoices.TRUNK_4C1P,
+        )
+        cable.save()
+
+        table = CableTable(Cable.objects.filter(pk=cable.pk))
+        table.columns.show('device_a')
+        table.columns.show('device_b')
+        row = list(table.rows)[0]
+
+        # Exported values include one parent per termination
+        self.assertEqual(row.get_cell_value('a_terminations'), 'et-0/0/0,et-0/0/1,et-0/0/2,et-0/0/3')
+        self.assertEqual(row.get_cell_value('device_a'), 'switch1,switch1,switch1,switch1')
+        self.assertEqual(row.get_cell_value('b_terminations'), 'eth0,eth1,eth0,eth1')
+        self.assertEqual(row.get_cell_value('device_b'), 'server1,server1,server2,server2')
+
+        # Rendered values collapse repeated parents
+        self.assertEqual(row.get_cell('device_a').count('<a href='), 1)
+        self.assertEqual(row.get_cell('device_b').count('<a href='), 2)
+
 
 class CableBundleTableTestCase(TableTestCases.StandardTableTestCase):
     table = CableBundleTable

+ 179 - 0
netbox/dcim/tests/test_views.py

@@ -4438,6 +4438,185 @@ class CableTestCase(
         self.assertIn('not one of the available choices', response.content.decode())
         self.assertEqual(self._get_queryset().count(), initial_count)
 
+    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[])
+    def test_bulk_import_exceeding_profile_capacity(self):
+        """A record with more terminations than its profile permits reports a validation error."""
+        self.add_permissions('dcim.add_cable')
+        csv_data = (
+            "side_a_device,side_a_type,side_a_name,side_b_device,side_b_type,side_b_name,profile",
+            'Device 3,dcim.interface,Interface 1,Device 4,dcim.interface,'
+            '"Interface 1,Interface 2,Interface 3",breakout-1c2p-2c1p',
+        )
+        initial_count = self._get_queryset().count()
+        data = {
+            'data': '\n'.join(csv_data),
+            'format': ImportFormatChoices.CSV,
+            'csv_delimiter': CSVDelimiterChoices.AUTO,
+        }
+
+        response = self.client.post(self._get_url('bulk_import'), data)
+        self.assertHttpStatus(response, 200)
+        self.assertIn('only 2 are permitted', response.content.decode())
+        self.assertEqual(self._get_queryset().count(), initial_count)
+
+    def _post_cable_update(self, csv_data):
+        self.add_permissions('dcim.add_cable', 'dcim.change_cable')
+        return self.client.post(self._get_url('bulk_import'), {
+            'data': '\n'.join(csv_data),
+            'format': ImportFormatChoices.CSV,
+            'csv_delimiter': CSVDelimiterChoices.AUTO,
+        })
+
+    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[])
+    def test_bulk_update_terminations_without_parent_column(self):
+        """Redefining termination names without the parent column is rejected, not silently ignored."""
+        cable = self._get_queryset().first()
+        original = cable.b_terminations
+
+        response = self._post_cable_update((
+            "id,side_b_type,side_b_name",
+            f'{cable.pk},dcim.interface,"Interface 1,Interface 2"',
+        ))
+        self.assertHttpStatus(response, 200)
+        self.assertIn('side_b_device column must be included', response.content.decode())
+        self.assertEqual(Cable.objects.get(pk=cable.pk).b_terminations, original)
+
+    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[])
+    def test_bulk_update_terminations_without_type_column(self):
+        """The same applies to the termination type column."""
+        cable = self._get_queryset().first()
+        original = cable.b_terminations
+
+        response = self._post_cable_update((
+            "id,side_b_device,side_b_name",
+            f'{cable.pk},Device 4,"Interface 1,Interface 2"',
+        ))
+        self.assertHttpStatus(response, 200)
+        self.assertIn('side_b_type column must be included', response.content.decode())
+        self.assertEqual(Cable.objects.get(pk=cable.pk).b_terminations, original)
+
+    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[])
+    def test_bulk_update_profile_violation_without_termination_columns(self):
+        """
+        A profile change which conflicts with the cable's existing terminations reports a validation
+        error even though the record omits the termination columns.
+        """
+        interfaces = Interface.objects.filter(device__name='Device 4').order_by('name')
+        cable = Cable(
+            a_terminations=[Interface.objects.get(device__name='Device 3', name='Interface 1')],
+            b_terminations=[interfaces[0], interfaces[1]],
+            profile=CableProfileChoices.BREAKOUT_1C2P_2C1P,
+        )
+        cable.save()
+
+        response = self._post_cable_update((
+            "id,profile",
+            f'{cable.pk},{CableProfileChoices.SINGLE_1C1P}',
+        ))
+        self.assertHttpStatus(response, 200)
+        self.assertIn('only 1 are permitted', response.content.decode())
+        self.assertEqual(
+            Cable.objects.get(pk=cable.pk).profile, CableProfileChoices.BREAKOUT_1C2P_2C1P
+        )
+
+    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[])
+    def test_bulk_update_terminations_with_all_columns(self):
+        """A complete set of side columns updates the terminations."""
+        cable = self._get_queryset().first()
+
+        response = self._post_cable_update((
+            "id,side_b_device,side_b_type,side_b_name,profile",
+            f'{cable.pk},Device 4,dcim.interface,"Interface 1,Interface 2",breakout-1c2p-2c1p',
+        ))
+        self.assertHttpStatus(response, 302)
+        self.assertEqual(
+            [str(t) for t in Cable.objects.get(pk=cable.pk).b_terminations],
+            ['Interface 1', 'Interface 2']
+        )
+
+    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[])
+    def test_bulk_update_side_columns_without_name_column(self):
+        """Supporting side columns without the name column are rejected, not silently ignored."""
+        cable = self._get_queryset().first()
+        original = cable.b_terminations
+
+        response = self._post_cable_update((
+            "id,side_b_device,side_b_type",
+            f'{cable.pk},Device 4,dcim.interface',
+        ))
+        self.assertHttpStatus(response, 200)
+        content = response.content.decode()
+        self.assertIn('side_b_name column must be included', content)
+        self.assertIn('side_b_device, side_b_type', content)
+        self.assertEqual(Cable.objects.get(pk=cable.pk).b_terminations, original)
+
+    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[])
+    def test_bulk_update_site_column_without_name_column(self):
+        """The site column only scopes termination resolution, so it too requires the name column."""
+        cable = self._get_queryset().first()
+        original = cable.b_terminations
+
+        response = self._post_cable_update((
+            "id,side_b_site",
+            f'{cable.pk},Site 1',
+        ))
+        self.assertHttpStatus(response, 200)
+        self.assertIn('side_b_name column must be included', response.content.decode())
+        self.assertEqual(Cable.objects.get(pk=cable.pk).b_terminations, original)
+
+    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[])
+    def test_bulk_update_without_any_side_columns(self):
+        """An update touching no side columns is unaffected by the name column requirement."""
+        cable = self._get_queryset().first()
+        original = cable.b_terminations
+
+        response = self._post_cable_update((
+            "id,label",
+            f'{cable.pk},Relabeled',
+        ))
+        self.assertHttpStatus(response, 302)
+        cable = Cable.objects.get(pk=cable.pk)
+        self.assertEqual(cable.label, 'Relabeled')
+        self.assertEqual(cable.b_terminations, original)
+
+    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[])
+    def test_bulk_update_blank_name_column(self):
+        """A blank name column alongside its supporting columns is rejected, not silently ignored."""
+        cable = self._get_queryset().first()
+        original = cable.b_terminations
+
+        response = self._post_cable_update((
+            "id,side_b_device,side_b_type,side_b_name",
+            f'{cable.pk},Device 4,dcim.interface,',
+        ))
+        self.assertHttpStatus(response, 200)
+        self.assertIn('side_b_name: This field is required', response.content.decode())
+        self.assertEqual(Cable.objects.get(pk=cable.pk).b_terminations, original)
+
+    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[])
+    def test_bulk_update_reorders_terminations(self):
+        """Reordering a side's terminations rewires the cable, even though its members are unchanged."""
+        interfaces = Interface.objects.filter(device__name='Device 4').order_by('name')[:2]
+        cable = Cable(
+            a_terminations=[Interface.objects.get(device__name='Device 3', name='Interface 1')],
+            b_terminations=[interfaces[0], interfaces[1]],
+            profile=CableProfileChoices.BREAKOUT_1C2P_2C1P,
+        )
+        cable.save()
+
+        response = self._post_cable_update((
+            "id,side_b_device,side_b_type,side_b_name",
+            f'{cable.pk},Device 4,dcim.interface,"{interfaces[1].name},{interfaces[0].name}"',
+        ))
+        self.assertHttpStatus(response, 302)
+        self.assertEqual(
+            [
+                (ct.connector, ct.termination)
+                for ct in Cable.objects.get(pk=cable.pk).terminations.filter(cable_end=CableEndChoices.SIDE_B)
+            ],
+            [(1, interfaces[1]), (2, interfaces[0])]
+        )
+
 
 #
 # Connections