Просмотр исходного кода

Closes #20151: Add support for cable bundles (#21636)

bctiemann 1 день назад
Родитель
Сommit
02165a28a0

+ 1 - 0
docs/development/models.md

@@ -45,6 +45,7 @@ These are considered the "core" application models which are used to model netwo
 * [core.DataSource](../models/core/datasource.md)
 * [core.Job](../models/core/job.md)
 * [dcim.Cable](../models/dcim/cable.md)
+* [dcim.CableBundle](../models/dcim/cablebundle.md)
 * [dcim.Device](../models/dcim/device.md)
 * [dcim.DeviceType](../models/dcim/devicetype.md)
 * [dcim.Module](../models/dcim/module.md)

+ 15 - 0
docs/models/dcim/cablebundle.md

@@ -0,0 +1,15 @@
+# Cable Bundles
+
+A cable bundle is a logical grouping of individual [cables](./cable.md). Bundles can be used to organize cables that share a common purpose, route, or physical grouping (such as a conduit or harness).
+
+Assigning cables to a bundle is optional and does not affect cable tracing or connectivity. Bundles persist independently of their member cables: deleting a cable clears its bundle assignment but does not delete the bundle itself.
+
+## Fields
+
+### Name
+
+A unique name for the cable bundle.
+
+### Description
+
+A brief description of the bundle's purpose or contents.

+ 1 - 0
mkdocs.yml

@@ -189,6 +189,7 @@ nav:
             - Job: 'models/core/job.md'
         - DCIM:
             - Cable: 'models/dcim/cable.md'
+            - CableBundle: 'models/dcim/cablebundle.md'
             - ConsolePort: 'models/dcim/consoleport.md'
             - ConsolePortTemplate: 'models/dcim/consoleporttemplate.md'
             - ConsoleServerPort: 'models/dcim/consoleserverport.md'

+ 16 - 2
netbox/dcim/api/serializers_/cables.py

@@ -3,7 +3,7 @@ from drf_spectacular.utils import extend_schema_field
 from rest_framework import serializers
 
 from dcim.choices import *
-from dcim.models import Cable, CablePath, CableTermination
+from dcim.models import Cable, CableBundle, CablePath, CableTermination
 from netbox.api.fields import ChoiceField, ContentTypeField
 from netbox.api.gfk_fields import GFKSerializerField
 from netbox.api.serializers import (
@@ -16,6 +16,7 @@ from tenancy.api.serializers_.tenants import TenantSerializer
 from utilities.api import get_serializer_for_model
 
 __all__ = (
+    'CableBundleSerializer',
     'CablePathSerializer',
     'CableSerializer',
     'CableTerminationSerializer',
@@ -24,6 +25,18 @@ __all__ = (
 )
 
 
+class CableBundleSerializer(PrimaryModelSerializer):
+    cable_count = serializers.IntegerField(read_only=True, default=0)
+
+    class Meta:
+        model = CableBundle
+        fields = [
+            'id', 'url', 'display_url', 'display', 'name', 'description', 'owner', 'comments', 'tags',
+            'custom_fields', 'created', 'last_updated', 'cable_count',
+        ]
+        brief_fields = ('id', 'url', 'display', 'name', 'description')
+
+
 class CableSerializer(PrimaryModelSerializer):
     a_terminations = GenericObjectSerializer(many=True, required=False)
     b_terminations = GenericObjectSerializer(many=True, required=False)
@@ -31,12 +44,13 @@ class CableSerializer(PrimaryModelSerializer):
     profile = ChoiceField(choices=CableProfileChoices, required=False)
     tenant = TenantSerializer(nested=True, required=False, allow_null=True)
     length_unit = ChoiceField(choices=CableLengthUnitChoices, allow_blank=True, required=False, allow_null=True)
+    bundle = CableBundleSerializer(nested=True, required=False, allow_null=True, default=None)
 
     class Meta:
         model = Cable
         fields = [
             'id', 'url', 'display_url', 'display', 'type', 'a_terminations', 'b_terminations', 'status', 'profile',
-            'tenant', 'label', 'color', 'length', 'length_unit', 'description', 'owner', 'comments', 'tags',
+            'tenant', 'bundle', 'label', 'color', 'length', 'length_unit', 'description', 'owner', 'comments', 'tags',
             'custom_fields', 'created', 'last_updated',
         ]
         brief_fields = ('id', 'url', 'display', 'label', 'description')

+ 1 - 0
netbox/dcim/api/urls.py

@@ -64,6 +64,7 @@ router.register('mac-addresses', views.MACAddressViewSet)
 # Cables
 router.register('cables', views.CableViewSet)
 router.register('cable-terminations', views.CableTerminationViewSet)
+router.register('cable-bundles', views.CableBundleViewSet)
 
 # Virtual chassis
 router.register('virtual-chassis', views.VirtualChassisViewSet)

+ 9 - 0
netbox/dcim/api/views.py

@@ -19,6 +19,7 @@ from netbox.api.pagination import StripCountAnnotationsPaginator
 from netbox.api.viewsets import MPTTLockedMixin, NetBoxModelViewSet, NetBoxReadOnlyModelViewSet
 from netbox.api.viewsets.mixins import SequentialBulkCreatesMixin
 from utilities.api import get_serializer_for_model
+from utilities.query import count_related
 from utilities.query_functions import CollateAsChar
 from virtualization.models import VirtualMachine
 
@@ -584,6 +585,14 @@ class CableTerminationViewSet(NetBoxReadOnlyModelViewSet):
     filterset_class = filtersets.CableTerminationFilterSet
 
 
+class CableBundleViewSet(NetBoxModelViewSet):
+    queryset = CableBundle.objects.annotate(
+        cable_count=count_related(Cable, 'bundle')
+    )
+    serializer_class = serializers.CableBundleSerializer
+    filterset_class = filtersets.CableBundleFilterSet
+
+
 #
 # Virtual chassis
 #

+ 28 - 0
netbox/dcim/filtersets.py

@@ -45,6 +45,7 @@ from .constants import *
 from .models import *
 
 __all__ = (
+    'CableBundleFilterSet',
     'CableFilterSet',
     'CableTerminationFilterSet',
     'CabledObjectFilterSet',
@@ -2569,6 +2570,23 @@ class VirtualChassisFilterSet(PrimaryModelFilterSet):
         return queryset.filter(qs_filter).distinct()
 
 
+@register_filterset
+class CableBundleFilterSet(PrimaryModelFilterSet):
+
+    class Meta:
+        model = CableBundle
+        fields = ('id', 'name', 'description')
+
+    def search(self, queryset, name, value):
+        if not value.strip():
+            return queryset
+        return queryset.filter(
+            Q(name__icontains=value) |
+            Q(description__icontains=value) |
+            Q(comments__icontains=value)
+        )
+
+
 @register_filterset
 class CableFilterSet(TenancyFilterSet, PrimaryModelFilterSet):
     termination_a_type = MultiValueContentTypeFilter(
@@ -2589,6 +2607,16 @@ class CableFilterSet(TenancyFilterSet, PrimaryModelFilterSet):
         method='_unterminated',
         label=_('Unterminated'),
     )
+    bundle_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=CableBundle.objects.all(),
+        label=_('Cable bundle (ID)'),
+    )
+    bundle = django_filters.ModelMultipleChoiceFilter(
+        field_name='bundle__name',
+        queryset=CableBundle.objects.all(),
+        to_field_name='name',
+        label=_('Cable bundle (name)'),
+    )
     type = django_filters.MultipleChoiceFilter(
         choices=CableTypeChoices,
         distinct=False,

+ 26 - 2
netbox/dcim/forms/bulk_edit.py

@@ -29,6 +29,7 @@ from wireless.models import WirelessLAN, WirelessLANGroup
 
 __all__ = (
     'CableBulkEditForm',
+    'CableBundleBulkEditForm',
     'ConsolePortBulkEditForm',
     'ConsolePortTemplateBulkEditForm',
     'ConsoleServerPortBulkEditForm',
@@ -786,6 +787,24 @@ class ModuleBulkEditForm(PrimaryModelBulkEditForm):
     nullable_fields = ('serial', 'description', 'comments')
 
 
+class CableBundleBulkEditForm(PrimaryModelBulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=CableBundle.objects.all(),
+        widget=forms.MultipleHiddenInput
+    )
+    description = forms.CharField(
+        label=_('Description'),
+        max_length=200,
+        required=False,
+    )
+
+    model = CableBundle
+    fieldsets = (
+        FieldSet('description',),
+    )
+    nullable_fields = ('description', 'comments')
+
+
 class CableBulkEditForm(PrimaryModelBulkEditForm):
     type = forms.ChoiceField(
         label=_('Type'),
@@ -810,6 +829,11 @@ class CableBulkEditForm(PrimaryModelBulkEditForm):
         queryset=Tenant.objects.all(),
         required=False
     )
+    bundle = DynamicModelChoiceField(
+        label=_('Bundle'),
+        queryset=CableBundle.objects.all(),
+        required=False,
+    )
     label = forms.CharField(
         label=_('Label'),
         max_length=100,
@@ -833,11 +857,11 @@ class CableBulkEditForm(PrimaryModelBulkEditForm):
 
     model = Cable
     fieldsets = (
-        FieldSet('type', 'status', 'profile', 'tenant', 'label', 'description'),
+        FieldSet('type', 'status', 'profile', 'tenant', 'bundle', 'label', 'description'),
         FieldSet('color', 'length', 'length_unit', name=_('Attributes')),
     )
     nullable_fields = (
-        'type', 'status', 'profile', 'tenant', 'label', 'color', 'length', 'description', 'comments',
+        'type', 'status', 'profile', 'tenant', 'bundle', 'label', 'color', 'length', 'description', 'comments',
     )
 
 

+ 15 - 1
netbox/dcim/forms/bulk_import.py

@@ -34,6 +34,7 @@ from wireless.choices import WirelessRoleChoices
 from .common import ModuleCommonForm
 
 __all__ = (
+    'CableBundleImportForm',
     'CableImportForm',
     'ConsolePortImportForm',
     'ConsoleServerPortImportForm',
@@ -1412,6 +1413,12 @@ class MACAddressImportForm(PrimaryModelImportForm):
 # Cables
 #
 
+class CableBundleImportForm(PrimaryModelImportForm):
+    class Meta:
+        model = CableBundle
+        fields = ('name', 'description', 'owner', 'comments', 'tags')
+
+
 class CableImportForm(PrimaryModelImportForm):
     # Termination A
     side_a_site = CSVModelChoiceField(
@@ -1489,6 +1496,13 @@ class CableImportForm(PrimaryModelImportForm):
         to_field_name='name',
         help_text=_('Assigned tenant')
     )
+    bundle = CSVModelChoiceField(
+        label=_('Bundle'),
+        queryset=CableBundle.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text=_('Cable bundle name'),
+    )
     length_unit = CSVChoiceField(
         label=_('Length unit'),
         choices=CableLengthUnitChoices,
@@ -1506,7 +1520,7 @@ class CableImportForm(PrimaryModelImportForm):
         model = Cable
         fields = [
             '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', 'profile', 'tenant', 'label', 'color', 'length', 'length_unit',
+            'side_b_name', 'type', 'status', 'profile', 'tenant', 'bundle', 'label', 'color', 'length', 'length_unit',
             'description', 'owner', 'comments', 'tags',
         ]
 

+ 19 - 1
netbox/dcim/forms/filtersets.py

@@ -27,6 +27,7 @@ from vpn.models import L2VPN
 from wireless.choices import *
 
 __all__ = (
+    'CableBundleFilterForm',
     'CableFilterForm',
     'ConsoleConnectionFilterForm',
     'ConsolePortFilterForm',
@@ -1172,12 +1173,24 @@ class VirtualChassisFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
     tag = TagFilterField(model)
 
 
+class CableBundleFilterForm(PrimaryModelFilterSetForm):
+    model = CableBundle
+    fieldsets = (
+        FieldSet('q', 'filter_id', 'tag'),
+        FieldSet('name', name=_('Attributes')),
+    )
+    tag = TagFilterField(model)
+
+
 class CableFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
     model = Cable
     fieldsets = (
         FieldSet('q', 'filter_id', 'tag'),
         FieldSet('site_id', 'location_id', 'rack_id', 'device_id', name=_('Location')),
-        FieldSet('type', 'status', 'profile', 'color', 'length', 'length_unit', 'unterminated', name=_('Attributes')),
+        FieldSet(
+            'type', 'status', 'profile', 'color', 'length', 'length_unit', 'unterminated', 'bundle_id',
+            name=_('Attributes'),
+        ),
         FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
         FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
     )
@@ -1259,6 +1272,11 @@ class CableFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
             choices=BOOLEAN_WITH_BLANK_CHOICES
         )
     )
+    bundle_id = DynamicModelMultipleChoiceField(
+        queryset=CableBundle.objects.all(),
+        required=False,
+        label=_('Bundle'),
+    )
     tag = TagFilterField(model)
 
 

+ 18 - 1
netbox/dcim/forms/model_forms.py

@@ -39,6 +39,7 @@ from wireless.models import WirelessLAN, WirelessLANGroup
 from .common import InterfaceCommonForm, ModuleCommonForm
 
 __all__ = (
+    'CableBundleForm',
     'CableForm',
     'ConsolePortForm',
     'ConsolePortTemplateForm',
@@ -830,6 +831,17 @@ def get_termination_type_choices():
     ])
 
 
+class CableBundleForm(PrimaryModelForm):
+
+    fieldsets = (
+        FieldSet('name', 'description', 'tags', name=_('Cable Bundle')),
+    )
+
+    class Meta:
+        model = CableBundle
+        fields = ['name', 'description', 'owner', 'comments', 'tags']
+
+
 class CableForm(TenancyForm, PrimaryModelForm):
     a_terminations_type = forms.ChoiceField(
         choices=get_termination_type_choices,
@@ -843,12 +855,17 @@ class CableForm(TenancyForm, PrimaryModelForm):
         widget=HTMXSelect(),
         label=_('Type')
     )
+    bundle = DynamicModelChoiceField(
+        queryset=CableBundle.objects.all(),
+        required=False,
+        label=_('Bundle'),
+    )
 
     class Meta:
         model = Cable
         fields = [
             'a_terminations_type', 'b_terminations_type', 'type', 'status', 'profile', 'tenant_group', 'tenant',
-            'label', 'color', 'length', 'length_unit', 'description', 'owner', 'comments', 'tags',
+            'bundle', 'label', 'color', 'length', 'length_unit', 'description', 'owner', 'comments', 'tags',
         ]
 
 

+ 6 - 0
netbox/dcim/graphql/filters.py

@@ -57,6 +57,7 @@ if TYPE_CHECKING:
     from .enums import *
 
 __all__ = (
+    'CableBundleFilter',
     'CableFilter',
     'CableTerminationFilter',
     'ConsolePortFilter',
@@ -107,6 +108,11 @@ __all__ = (
 )
 
 
+@strawberry_django.filter_type(models.CableBundle, lookups=True)
+class CableBundleFilter(PrimaryModelFilter):
+    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
+
+
 @strawberry_django.filter_type(models.Cable, lookups=True)
 class CableFilter(TenancyFilterMixin, PrimaryModelFilter):
     type: BaseFilterLookup[Annotated['CableTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (

+ 3 - 0
netbox/dcim/graphql/schema.py

@@ -9,6 +9,9 @@ class DCIMQuery:
     cable: CableType = strawberry_django.field()
     cable_list: list[CableType] = strawberry_django.field()
 
+    cable_bundle: CableBundleType = strawberry_django.field()
+    cable_bundle_list: list[CableBundleType] = strawberry_django.field()
+
     console_port: ConsolePortType = strawberry_django.field()
     console_port_list: list[ConsolePortType] = strawberry_django.field()
 

+ 12 - 0
netbox/dcim/graphql/types.py

@@ -39,6 +39,7 @@ if TYPE_CHECKING:
     from wireless.graphql.types import WirelessLANType, WirelessLinkType
 
 __all__ = (
+    'CableBundleType',
     'CableType',
     'ComponentType',
     'ConsolePortTemplateType',
@@ -127,6 +128,16 @@ class ModularComponentTemplateType(ComponentTemplateType):
 #
 
 
+@strawberry_django.type(
+    models.CableBundle,
+    fields='__all__',
+    filters=CableBundleFilter,
+    pagination=True
+)
+class CableBundleType(PrimaryObjectType):
+    cables: list[Annotated['CableType', strawberry.lazy('dcim.graphql.types')]]
+
+
 @strawberry_django.type(
     models.CableTermination,
     exclude=['termination_type', 'termination_id', '_device', '_rack', '_location', '_site'],
@@ -158,6 +169,7 @@ class CableTerminationType(NetBoxObjectType):
 class CableType(PrimaryObjectType):
     color: str
     tenant: Annotated['TenantType', strawberry.lazy('tenancy.graphql.types')] | None
+    bundle: Annotated['CableBundleType', strawberry.lazy('dcim.graphql.types')] | None
 
     terminations: list[CableTerminationType]
 

+ 54 - 0
netbox/dcim/migrations/0228_cable_bundle.py

@@ -0,0 +1,54 @@
+import django.db.models.deletion
+import taggit.managers
+from django.db import migrations, models
+
+import netbox.models.deletion
+import utilities.json
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0227_rack_group'),
+        ('extras', '0134_owner'),
+        ('users', '0015_owner'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='CableBundle',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
+                ('created', models.DateTimeField(auto_now_add=True, null=True)),
+                ('last_updated', models.DateTimeField(auto_now=True, null=True)),
+                ('custom_field_data', models.JSONField(
+                    blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)
+                 ),
+                ('description', models.CharField(blank=True, max_length=200)),
+                ('comments', models.TextField(blank=True)),
+                ('name', models.CharField(max_length=100, unique=True)),
+                ('owner', models.ForeignKey(
+                    blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner')
+                 ),
+                ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
+            ],
+            options={
+                'verbose_name': 'cable bundle',
+                'verbose_name_plural': 'cable bundles',
+                'ordering': ('name',),
+            },
+            bases=(netbox.models.deletion.DeleteMixin, models.Model),
+        ),
+        migrations.AddField(
+            model_name='cable',
+            name='bundle',
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.SET_NULL,
+                related_name='cables',
+                to='dcim.cablebundle',
+                verbose_name='bundle',
+            ),
+        ),
+    ]

+ 37 - 1
netbox/dcim/models/cables.py

@@ -8,6 +8,7 @@ from django.core.exceptions import ValidationError
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.db import models
 from django.dispatch import Signal
+from django.urls import reverse
 from django.utils.translation import gettext_lazy as _
 
 from core.models import ObjectType
@@ -29,6 +30,7 @@ from .device_components import FrontPort, PathEndpoint, PortMapping, RearPort
 
 __all__ = (
     'Cable',
+    'CableBundle',
     'CablePath',
     'CableTermination',
 )
@@ -38,6 +40,32 @@ logger = logging.getLogger(f'netbox.{__name__}')
 trace_paths = Signal()
 
 
+#
+# Cable bundles
+#
+
+class CableBundle(PrimaryModel):
+    """
+    A logical grouping of individual cables.
+    """
+    name = models.CharField(
+        verbose_name=_('name'),
+        max_length=100,
+        unique=True,
+    )
+
+    class Meta:
+        ordering = ('name',)
+        verbose_name = _('cable bundle')
+        verbose_name_plural = _('cable bundles')
+
+    def __str__(self):
+        return self.name
+
+    def get_absolute_url(self):
+        return reverse('dcim:cablebundle', args=[self.pk])
+
+
 #
 # Cables
 #
@@ -102,8 +130,16 @@ class Cable(PrimaryModel):
         blank=True,
         null=True
     )
+    bundle = models.ForeignKey(
+        to='dcim.CableBundle',
+        on_delete=models.SET_NULL,
+        related_name='cables',
+        blank=True,
+        null=True,
+        verbose_name=_('bundle'),
+    )
 
-    clone_fields = ('tenant', 'type', 'profile')
+    clone_fields = ('tenant', 'type', 'profile', 'bundle')
 
     class Meta:
         ordering = ('pk',)

+ 11 - 0
netbox/dcim/search.py

@@ -3,6 +3,17 @@ from netbox.search import SearchIndex, register_search
 from . import models
 
 
+@register_search
+class CableBundleIndex(SearchIndex):
+    model = models.CableBundle
+    fields = (
+        ('name', 100),
+        ('description', 500),
+        ('comments', 5000),
+    )
+    display_attrs = ('description',)
+
+
 @register_search
 class CableIndex(SearchIndex):
     model = models.Cable

+ 29 - 2
netbox/dcim/tables/cables.py

@@ -4,13 +4,14 @@ from django.utils.safestring import mark_safe
 from django.utils.translation import gettext_lazy as _
 from django_tables2.utils import Accessor
 
-from dcim.models import Cable
+from dcim.models import Cable, CableBundle
 from netbox.tables import PrimaryModelTable, columns
 from tenancy.tables import TenancyColumnsMixin
 
 from .template_code import CABLE_LENGTH
 
 __all__ = (
+    'CableBundleTable',
     'CableTable',
 )
 
@@ -119,6 +120,10 @@ class CableTable(TenancyColumnsMixin, PrimaryModelTable):
         verbose_name=_('Color Name'),
         orderable=False
     )
+    bundle = tables.Column(
+        verbose_name=_('Bundle'),
+        linkify=True,
+    )
     tags = columns.TagColumn(
         url_name='dcim:cable_list'
     )
@@ -128,8 +133,30 @@ class CableTable(TenancyColumnsMixin, PrimaryModelTable):
         fields = (
             'pk', 'id', 'label', 'a_terminations', 'b_terminations', 'device_a', 'device_b', 'rack_a', 'rack_b',
             'location_a', 'location_b', 'site_a', 'site_b', 'status', 'profile', 'type', 'tenant', 'tenant_group',
-            'color', 'color_name', 'length', 'description', 'comments', 'tags', 'created', 'last_updated',
+            'color', 'color_name', 'bundle', 'length', 'description', 'comments', 'tags', 'created', 'last_updated',
         )
         default_columns = (
             'pk', 'id', 'label', 'a_terminations', 'b_terminations', 'status', 'type',
         )
+
+
+class CableBundleTable(PrimaryModelTable):
+    name = tables.Column(
+        verbose_name=_('Name'),
+        linkify=True,
+    )
+    cable_count = tables.Column(
+        verbose_name=_('Cables'),
+    )
+    tags = columns.TagColumn(
+        url_name='dcim:cablebundle_list'
+    )
+
+    class Meta(PrimaryModelTable.Meta):
+        model = CableBundle
+        fields = (
+            'pk', 'id', 'name', 'cable_count', 'description', 'tags', 'created', 'last_updated',
+        )
+        default_columns = (
+            'pk', 'id', 'name', 'cable_count', 'description',
+        )

+ 54 - 0
netbox/dcim/tests/test_api.py

@@ -2799,6 +2799,60 @@ class InventoryItemRoleTest(APIViewTestCases.APIViewTestCase):
         InventoryItemRole.objects.bulk_create(roles)
 
 
+class CableBundleTest(APIViewTestCases.APIViewTestCase):
+    model = CableBundle
+    brief_fields = ['description', 'display', 'id', 'name', 'url']
+    create_data = [
+        {'name': 'Cable Bundle 4'},
+        {'name': 'Cable Bundle 5'},
+        {'name': 'Cable Bundle 6'},
+    ]
+    bulk_update_data = {
+        'description': 'New description',
+    }
+
+    @classmethod
+    def setUpTestData(cls):
+        cable_bundles = (
+            CableBundle(name='Cable Bundle 1'),
+            CableBundle(name='Cable Bundle 2'),
+            CableBundle(name='Cable Bundle 3'),
+        )
+        CableBundle.objects.bulk_create(cable_bundles)
+
+    def test_cable_count(self):
+        """cable_count annotation is returned correctly in the API response."""
+        self.add_permissions('dcim.view_cablebundle')
+        bundle = CableBundle.objects.first()
+
+        site = Site.objects.create(name='CB Test Site', slug='cb-test-site')
+        manufacturer = Manufacturer.objects.create(name='CB Manufacturer', slug='cb-manufacturer')
+        device_type = DeviceType.objects.create(
+            manufacturer=manufacturer, model='CB Device Type', slug='cb-device-type'
+        )
+        role = DeviceRole.objects.create(name='CB Role', slug='cb-role', color='ff0000')
+        devices = (
+            Device(device_type=device_type, role=role, name='CB Device 1', site=site),
+            Device(device_type=device_type, role=role, name='CB Device 2', site=site),
+        )
+        Device.objects.bulk_create(devices)
+        interfaces = (
+            Interface(device=devices[0], name='eth0', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
+            Interface(device=devices[0], name='eth1', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
+            Interface(device=devices[1], name='eth0', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
+            Interface(device=devices[1], name='eth1', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
+        )
+        Interface.objects.bulk_create(interfaces)
+        for a, b in [(interfaces[0], interfaces[2]), (interfaces[1], interfaces[3])]:
+            cable = Cable(a_terminations=[a], b_terminations=[b], bundle=bundle)
+            cable.save()
+
+        url = reverse('dcim-api:cablebundle-detail', kwargs={'pk': bundle.pk})
+        response = self.client.get(url, **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        self.assertEqual(response.data['cable_count'], 2)
+
+
 class CableTest(APIViewTestCases.APIViewTestCase):
     model = Cable
     brief_fields = ['description', 'display', 'id', 'label', 'url']

+ 26 - 0
netbox/dcim/tests/test_filtersets.py

@@ -6471,6 +6471,32 @@ class VirtualChassisTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
+class CableBundleTestCase(TestCase, ChangeLoggedFilterSetTests):
+    queryset = CableBundle.objects.all()
+    filterset = CableBundleFilterSet
+
+    @classmethod
+    def setUpTestData(cls):
+        cable_bundles = (
+            CableBundle(name='Cable Bundle 1', description='foobar1'),
+            CableBundle(name='Cable Bundle 2', description='foobar2'),
+            CableBundle(name='Cable Bundle 3'),
+        )
+        CableBundle.objects.bulk_create(cable_bundles)
+
+    def test_q(self):
+        params = {'q': 'foobar1'}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+    def test_name(self):
+        params = {'name': ['Cable Bundle 1', 'Cable Bundle 2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_description(self):
+        params = {'description': ['foobar1', 'foobar2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+
 class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = Cable.objects.all()
     filterset = CableFilterSet

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

@@ -3547,6 +3547,45 @@ class InventoryItemRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
         }
 
 
+class CableBundleTestCase(ViewTestCases.PrimaryObjectViewTestCase):
+    model = CableBundle
+
+    @classmethod
+    def setUpTestData(cls):
+        cable_bundles = (
+            CableBundle(name='Cable Bundle 1'),
+            CableBundle(name='Cable Bundle 2'),
+            CableBundle(name='Cable Bundle 3'),
+        )
+        CableBundle.objects.bulk_create(cable_bundles)
+
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
+
+        cls.form_data = {
+            'name': 'Cable Bundle X',
+            'description': 'A test bundle',
+            'tags': [t.pk for t in tags],
+        }
+
+        cls.csv_data = (
+            "name,description",
+            "Cable Bundle 4,Fourth bundle",
+            "Cable Bundle 5,Fifth bundle",
+            "Cable Bundle 6,",
+        )
+
+        cls.csv_update_data = (
+            "id,name,description",
+            f"{cable_bundles[0].pk},Cable Bundle 7,New description7",
+            f"{cable_bundles[1].pk},Cable Bundle 8,New description8",
+            f"{cable_bundles[2].pk},Cable Bundle 9,New description9",
+        )
+
+        cls.bulk_edit_data = {
+            'description': 'New description',
+        }
+
+
 # TODO: Change base class to PrimaryObjectViewTestCase
 # Blocked by lack of common creation view for cables (termination A must be initialized)
 class CableTestCase(

+ 3 - 0
netbox/dcim/urls.py

@@ -153,6 +153,9 @@ urlpatterns = [
     path('cables/', include(get_model_urls('dcim', 'cable', detail=False))),
     path('cables/<int:pk>/', include(get_model_urls('dcim', 'cable'))),
 
+    path('cable-bundles/', include(get_model_urls('dcim', 'cablebundle', detail=False))),
+    path('cable-bundles/<int:pk>/', include(get_model_urls('dcim', 'cablebundle'))),
+
     # Console/power/interface connections (read-only)
     path('console-connections/', views.ConsoleConnectionsListView.as_view(), name='console_connections_list'),
     path('power-connections/', views.PowerConnectionsListView.as_view(), name='power_connections_list'),

+ 66 - 0
netbox/dcim/views.py

@@ -4017,6 +4017,72 @@ class DeviceBulkAddInventoryItemView(generic.BulkComponentCreateView):
     default_return_url = 'dcim:device_list'
 
 
+#
+# Cable bundles
+#
+
+@register_model_view(CableBundle, 'list', path='', detail=False)
+class CableBundleListView(generic.ObjectListView):
+    queryset = CableBundle.objects.annotate(
+        cable_count=count_related(Cable, 'bundle')
+    )
+    filterset = filtersets.CableBundleFilterSet
+    filterset_form = forms.CableBundleFilterForm
+    table = tables.CableBundleTable
+
+
+@register_model_view(CableBundle)
+class CableBundleView(generic.ObjectView):
+    queryset = CableBundle.objects.all()
+
+    def get_extra_context(self, request, instance):
+        cables_table = tables.CableTable(
+            instance.cables.all().prefetch_related(
+                'terminations__termination', 'terminations___device', 'terminations___rack', 'terminations___location',
+                'terminations___site',
+            ),
+            orderable=False,
+        )
+        cables_table.configure(request)
+
+        return {
+            'cables_table': cables_table,
+        }
+
+
+@register_model_view(CableBundle, 'add', detail=False)
+@register_model_view(CableBundle, 'edit')
+class CableBundleEditView(generic.ObjectEditView):
+    queryset = CableBundle.objects.all()
+    form = forms.CableBundleForm
+
+
+@register_model_view(CableBundle, 'delete')
+class CableBundleDeleteView(generic.ObjectDeleteView):
+    queryset = CableBundle.objects.all()
+
+
+@register_model_view(CableBundle, 'bulk_import', path='import', detail=False)
+class CableBundleBulkImportView(generic.BulkImportView):
+    queryset = CableBundle.objects.all()
+    model_form = forms.CableBundleImportForm
+
+
+@register_model_view(CableBundle, 'bulk_edit', path='edit', detail=False)
+class CableBundleBulkEditView(generic.BulkEditView):
+    queryset = CableBundle.objects.all()
+    filterset = filtersets.CableBundleFilterSet
+    table = tables.CableBundleTable
+    form = forms.CableBundleBulkEditForm
+
+
+@register_model_view(CableBundle, 'bulk_delete', path='delete', detail=False)
+class CableBundleBulkDeleteView(generic.BulkDeleteView):
+    queryset = CableBundle.objects.all()
+    filterset = filtersets.CableBundleFilterSet
+    table = tables.CableBundleTable
+
+
 #
 # Cables
 #

+ 1 - 0
netbox/extras/tests/test_filtersets.py

@@ -1223,6 +1223,7 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
         'asn',
         'asnrange',
         'cable',
+        'cablebundle',
         'circuit',
         'circuitgroup',
         'circuitgroupassignment',

+ 1 - 0
netbox/netbox/navigation/menu.py

@@ -131,6 +131,7 @@ CONNECTIONS_MENU = Menu(
             label=_('Connections'),
             items=(
                 get_model_item('dcim', 'cable', _('Cables')),
+                get_model_item('dcim', 'cablebundle', _('Cable Bundles')),
                 get_model_item('wireless', 'wirelesslink', _('Wireless Links')),
                 MenuItem(
                     link='dcim:interface_connections_list',

+ 4 - 0
netbox/templates/dcim/cable.html

@@ -32,6 +32,10 @@
               {{ object.tenant|linkify|placeholder }}
             </td>
           </tr>
+          <tr>
+            <th scope="row">{% trans "Bundle" %}</th>
+            <td>{{ object.bundle|linkify|placeholder }}</td>
+          </tr>
           <tr>
             <th scope="row">{% trans "Label" %}</th>
             <td>{{ object.label|placeholder }}</td>

+ 41 - 0
netbox/templates/dcim/cablebundle.html

@@ -0,0 +1,41 @@
+{% extends 'generic/object.html' %}
+{% load helpers %}
+{% load plugins %}
+{% load i18n %}
+
+{% block breadcrumbs %}
+  <li class="breadcrumb-item"><a href="{% url 'dcim:cablebundle_list' %}">{% trans "Cable Bundles" %}</a></li>
+{% endblock %}
+
+{% block content %}
+  <div class="row mb-3">
+    <div class="col col-12 col-md-6">
+      <div class="card">
+        <h2 class="card-header">{% trans "Cable Bundle" %}</h2>
+        <table class="table table-hover attr-table">
+          <tr>
+            <th scope="row">{% trans "Name" %}</th>
+            <td>{{ object.name }}</td>
+          </tr>
+          <tr>
+            <th scope="row">{% trans "Description" %}</th>
+            <td>{{ object.description|placeholder }}</td>
+          </tr>
+        </table>
+      </div>
+      {% include 'inc/panels/custom_fields.html' %}
+      {% include 'inc/panels/tags.html' %}
+      {% include 'inc/panels/comments.html' %}
+      {% plugin_left_page object %}
+    </div>
+    <div class="col col-12 col-md-6">
+      {% plugin_right_page object %}
+    </div>
+  </div>
+  <div class="row mb-3">
+    <div class="col col-md-12">
+      {% include 'inc/panel_table.html' with table=cables_table heading=_('Cables') %}
+      {% plugin_full_width_page object %}
+    </div>
+  </div>
+{% endblock %}

+ 1 - 0
netbox/templates/dcim/htmx/cable_edit.html

@@ -55,6 +55,7 @@
   {% render_field form.status %}
   {% render_field form.profile %}
   {% render_field form.type %}
+  {% render_field form.bundle %}
   {% render_field form.label %}
   {% render_field form.description %}
   {% render_field form.color %}