parent
80cc7e0d91
commit
02165a28a0
|
|
@ -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,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')
|
||||
|
|
|
|||
|
|
@ -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,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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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,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',
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -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',)
|
||||
|
|
|
|||
|
|
@ -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,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']
|
||||
|
|
|
|||
|
|
@ -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,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
|
||||
#
|
||||
|
|
|
|||
|
|
@ -1223,6 +1223,7 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||
'asn',
|
||||
'asnrange',
|
||||
'cable',
|
||||
'cablebundle',
|
||||
'circuit',
|
||||
'circuitgroup',
|
||||
'circuitgroupassignment',
|
||||
|
|
|
|||
|
|
@ -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,41 @@
|
|||
{% extends 'generic/object.html' %}
|
||||
{% load helpers %}
|
||||
{% load plugins %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block breadcrumbs %}
|
||||
<li class="breadcrumb-item"><a href="{% url 'dcim:cablebundle_list' %}">{% trans "Cable Bundles" %}</a></li>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-3">
|
||||
<div class="col col-12 col-md-6">
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Cable Bundle" %}</h2>
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">{% trans "Name" %}</th>
|
||||
<td>{{ object.name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Description" %}</th>
|
||||
<td>{{ object.description|placeholder }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
{% include 'inc/panels/custom_fields.html' %}
|
||||
{% include 'inc/panels/tags.html' %}
|
||||
{% include 'inc/panels/comments.html' %}
|
||||
{% plugin_left_page object %}
|
||||
</div>
|
||||
<div class="col col-12 col-md-6">
|
||||
{% plugin_right_page object %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col col-md-12">
|
||||
{% include 'inc/panel_table.html' with table=cables_table heading=_('Cables') %}
|
||||
{% plugin_full_width_page object %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
@ -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 %}
|
||||
|
|
|
|||
Loading…
Reference in New Issue