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

This commit is contained in:
bctiemann 2026-03-11 12:43:40 -04:00 committed by GitHub
parent 80cc7e0d91
commit 02165a28a0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 537 additions and 10 deletions

View File

@ -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)

View File

@ -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.

View File

@ -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'

View File

@ -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')

View File

@ -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)

View File

@ -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
#

View File

@ -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,

View File

@ -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',
)

View File

@ -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',
]

View File

@ -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)

View File

@ -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',
]

View File

@ -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 = (

View File

@ -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()

View File

@ -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]

View File

@ -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',
),
),
]

View File

@ -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',)

View File

@ -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

View File

@ -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',
)

View File

@ -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']

View File

@ -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

View File

@ -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(

View File

@ -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'),

View File

@ -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
#

View File

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

View File

@ -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',

View File

@ -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>

View File

@ -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 %}

View File

@ -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 %}