Add CableBundle model and all associated furniture

This commit is contained in:
Brian Tiemann
2026-03-10 15:23:18 -04:00
parent e2665ef211
commit f1cace5a5f
27 changed files with 502 additions and 9 deletions
+1
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)
+15
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.
+1
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'
+15 -2
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,17 @@ __all__ = (
)
class CableBundleSerializer(PrimaryModelSerializer):
class Meta:
model = CableBundle
fields = [
'id', 'url', 'display_url', 'display', 'name', 'description', 'comments', 'tags',
'custom_fields', 'created', 'last_updated',
]
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 +43,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
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)
+9
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
#
+27
View File
@@ -45,6 +45,7 @@ from .constants import *
from .models import *
__all__ = (
'CableBundleFilterSet',
'CableFilterSet',
'CableTerminationFilterSet',
'CabledObjectFilterSet',
@@ -2569,6 +2570,22 @@ 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)
)
@register_filterset
class CableFilterSet(TenancyFilterSet, PrimaryModelFilterSet):
termination_a_type = MultiValueContentTypeFilter(
@@ -2589,6 +2606,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,
+24 -1
View File
@@ -29,6 +29,7 @@ from wireless.models import WirelessLAN, WirelessLANGroup
__all__ = (
'CableBulkEditForm',
'CableBundleBulkEditForm',
'ConsolePortBulkEditForm',
'ConsolePortTemplateBulkEditForm',
'ConsoleServerPortBulkEditForm',
@@ -786,6 +787,23 @@ 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
class Meta:
nullable_fields = ('description',)
class CableBulkEditForm(PrimaryModelBulkEditForm):
type = forms.ChoiceField(
label=_('Type'),
@@ -810,6 +828,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,
@@ -837,7 +860,7 @@ class CableBulkEditForm(PrimaryModelBulkEditForm):
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
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', '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',
]
+18 -1
View File
@@ -27,6 +27,7 @@ from vpn.models import L2VPN
from wireless.choices import *
__all__ = (
'CableBundleFilterForm',
'CableFilterForm',
'ConsoleConnectionFilterForm',
'ConsolePortFilterForm',
@@ -1172,12 +1173,23 @@ class VirtualChassisFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
tag = TagFilterField(model)
class CableBundleFilterForm(PrimaryModelFilterSetForm):
model = CableBundle
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('name', name=_('Attributes')),
)
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 +1271,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
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', '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
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(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
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()
+12
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]
@@ -0,0 +1,53 @@
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)),
('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',
),
),
]
+36 -1
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,31 @@ 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
)
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 +129,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
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
+29 -2
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',
)
+22
View File
@@ -2799,6 +2799,28 @@ 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)
class CableTest(APIViewTestCases.APIViewTestCase):
model = Cable
brief_fields = ['description', 'display', 'id', 'label', 'url']
+26
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
+39
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(
+3
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'),
+65
View File
@@ -4017,6 +4017,71 @@ 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):
# Not prefetching terminations as they are not included in the default inline table layout. See CableListView
# for prefetch pattern if terminations are necessary here.
cables_table = tables.CableTable(
instance.cables.all(),
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
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',
+4
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>
+47
View File
@@ -0,0 +1,47 @@
{% 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>
<tr>
<th scope="row">{% trans "Cables" %}</th>
<td>
<a href="{% url 'dcim:cable_list' %}?bundle_id={{ object.pk }}">{{ object.cables.count }}</a>
</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 %}
@@ -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 %}