From 02165a28a08c975ac6d79a06b93c2ce0d3ae9eb7 Mon Sep 17 00:00:00 2001 From: bctiemann Date: Wed, 11 Mar 2026 12:43:40 -0400 Subject: [PATCH] Closes #20151: Add support for cable bundles (#21636) --- docs/development/models.md | 1 + docs/models/dcim/cablebundle.md | 15 +++++ mkdocs.yml | 1 + netbox/dcim/api/serializers_/cables.py | 18 +++++- netbox/dcim/api/urls.py | 1 + netbox/dcim/api/views.py | 9 +++ netbox/dcim/filtersets.py | 28 +++++++++ netbox/dcim/forms/bulk_edit.py | 28 ++++++++- netbox/dcim/forms/bulk_import.py | 16 ++++- netbox/dcim/forms/filtersets.py | 20 ++++++- netbox/dcim/forms/model_forms.py | 19 +++++- netbox/dcim/graphql/filters.py | 6 ++ netbox/dcim/graphql/schema.py | 3 + netbox/dcim/graphql/types.py | 12 ++++ netbox/dcim/migrations/0228_cable_bundle.py | 54 +++++++++++++++++ netbox/dcim/models/cables.py | 38 +++++++++++- netbox/dcim/search.py | 11 ++++ netbox/dcim/tables/cables.py | 31 +++++++++- netbox/dcim/tests/test_api.py | 54 +++++++++++++++++ netbox/dcim/tests/test_filtersets.py | 26 ++++++++ netbox/dcim/tests/test_views.py | 39 ++++++++++++ netbox/dcim/urls.py | 3 + netbox/dcim/views.py | 66 +++++++++++++++++++++ netbox/extras/tests/test_filtersets.py | 1 + netbox/netbox/navigation/menu.py | 1 + netbox/templates/dcim/cable.html | 4 ++ netbox/templates/dcim/cablebundle.html | 41 +++++++++++++ netbox/templates/dcim/htmx/cable_edit.html | 1 + 28 files changed, 537 insertions(+), 10 deletions(-) create mode 100644 docs/models/dcim/cablebundle.md create mode 100644 netbox/dcim/migrations/0228_cable_bundle.py create mode 100644 netbox/templates/dcim/cablebundle.html diff --git a/docs/development/models.md b/docs/development/models.md index 7c11d5521..b6ff436a0 100644 --- a/docs/development/models.md +++ b/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) diff --git a/docs/models/dcim/cablebundle.md b/docs/models/dcim/cablebundle.md new file mode 100644 index 000000000..cf585b378 --- /dev/null +++ b/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. diff --git a/mkdocs.yml b/mkdocs.yml index f5c7b9663..7b7f5a2f1 100644 --- a/mkdocs.yml +++ b/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' diff --git a/netbox/dcim/api/serializers_/cables.py b/netbox/dcim/api/serializers_/cables.py index 2b49acbc5..e0c2204d6 100644 --- a/netbox/dcim/api/serializers_/cables.py +++ b/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') diff --git a/netbox/dcim/api/urls.py b/netbox/dcim/api/urls.py index 328e5ac0f..852a871bb 100644 --- a/netbox/dcim/api/urls.py +++ b/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) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 96af81cf1..a1ab5e926 100644 --- a/netbox/dcim/api/views.py +++ b/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 # diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 994d68674..e405129b8 100644 --- a/netbox/dcim/filtersets.py +++ b/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, diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index fff3fcddf..aaea26eef 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/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', ) diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index 1749153db..acdb0f638 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/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', ] diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 9fbdb6010..b1a54842f 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/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) diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index aedc30943..ca06b620b 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/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', ] diff --git a/netbox/dcim/graphql/filters.py b/netbox/dcim/graphql/filters.py index 191e9a8fd..85a7dfdf5 100644 --- a/netbox/dcim/graphql/filters.py +++ b/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 = ( diff --git a/netbox/dcim/graphql/schema.py b/netbox/dcim/graphql/schema.py index 1ee426a91..710254120 100644 --- a/netbox/dcim/graphql/schema.py +++ b/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() diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py index 49aa18076..b6af83cb0 100644 --- a/netbox/dcim/graphql/types.py +++ b/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] diff --git a/netbox/dcim/migrations/0228_cable_bundle.py b/netbox/dcim/migrations/0228_cable_bundle.py new file mode 100644 index 000000000..18c3a67f4 --- /dev/null +++ b/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', + ), + ), + ] diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index 585b18687..17848e2d8 100644 --- a/netbox/dcim/models/cables.py +++ b/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',) diff --git a/netbox/dcim/search.py b/netbox/dcim/search.py index fa5cb9156..3eebc6952 100644 --- a/netbox/dcim/search.py +++ b/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 diff --git a/netbox/dcim/tables/cables.py b/netbox/dcim/tables/cables.py index 06c1b3056..29c01ff48 100644 --- a/netbox/dcim/tables/cables.py +++ b/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', + ) diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index e0289a595..326678bfa 100644 --- a/netbox/dcim/tests/test_api.py +++ b/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'] diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index d4a53bca2..8afb4d05f 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/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 diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index c90e6a91b..3f7e90952 100644 --- a/netbox/dcim/tests/test_views.py +++ b/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( diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index f3e1d6675..86cf79924 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -153,6 +153,9 @@ urlpatterns = [ path('cables/', include(get_model_urls('dcim', 'cable', detail=False))), path('cables//', include(get_model_urls('dcim', 'cable'))), + path('cable-bundles/', include(get_model_urls('dcim', 'cablebundle', detail=False))), + path('cable-bundles//', 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'), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index ebcf60ec8..b9a324b02 100644 --- a/netbox/dcim/views.py +++ b/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 # diff --git a/netbox/extras/tests/test_filtersets.py b/netbox/extras/tests/test_filtersets.py index 0e6237f5f..56e7f27f7 100644 --- a/netbox/extras/tests/test_filtersets.py +++ b/netbox/extras/tests/test_filtersets.py @@ -1223,6 +1223,7 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests): 'asn', 'asnrange', 'cable', + 'cablebundle', 'circuit', 'circuitgroup', 'circuitgroupassignment', diff --git a/netbox/netbox/navigation/menu.py b/netbox/netbox/navigation/menu.py index 0e0fa31d0..399997593 100644 --- a/netbox/netbox/navigation/menu.py +++ b/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', diff --git a/netbox/templates/dcim/cable.html b/netbox/templates/dcim/cable.html index 8e685c514..42857a990 100644 --- a/netbox/templates/dcim/cable.html +++ b/netbox/templates/dcim/cable.html @@ -32,6 +32,10 @@ {{ object.tenant|linkify|placeholder }} + + {% trans "Bundle" %} + {{ object.bundle|linkify|placeholder }} + {% trans "Label" %} {{ object.label|placeholder }} diff --git a/netbox/templates/dcim/cablebundle.html b/netbox/templates/dcim/cablebundle.html new file mode 100644 index 000000000..cb1237f2a --- /dev/null +++ b/netbox/templates/dcim/cablebundle.html @@ -0,0 +1,41 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} +{% load i18n %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block content %} +
+
+
+

{% trans "Cable Bundle" %}

+ + + + + + + + + +
{% trans "Name" %}{{ object.name }}
{% trans "Description" %}{{ object.description|placeholder }}
+
+ {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/comments.html' %} + {% plugin_left_page object %} +
+
+ {% plugin_right_page object %} +
+
+
+
+ {% include 'inc/panel_table.html' with table=cables_table heading=_('Cables') %} + {% plugin_full_width_page object %} +
+
+{% endblock %} diff --git a/netbox/templates/dcim/htmx/cable_edit.html b/netbox/templates/dcim/htmx/cable_edit.html index 45d528217..056c1679c 100644 --- a/netbox/templates/dcim/htmx/cable_edit.html +++ b/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 %}