mirror of
https://github.com/netbox-community/netbox.git
synced 2026-03-21 20:18:38 -06:00
Add CableBundle model and all associated furniture
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
@@ -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'
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
#
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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',
|
||||
]
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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',
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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',
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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',)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
|
||||
#
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
Reference in New Issue
Block a user