diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md
index c49552edd..630e46b5b 100644
--- a/docs/release-notes/version-3.1.md
+++ b/docs/release-notes/version-3.1.md
@@ -3,11 +3,16 @@
!!! warning "PostgreSQL 10 Required"
NetBox v3.1 requires PostgreSQL 10 or later.
+### Breaking Changes
+
+* The `tenant` and `tenant_id` filters for the Cable model now filter on the tenant assigned directly to each cable, rather than on the parent object of either termination.
+
### Enhancements
* [#1337](https://github.com/netbox-community/netbox/issues/1337) - Add WWN field to interfaces
* [#3839](https://github.com/netbox-community/netbox/issues/3839) - Add `airflow` field for devices types and devices
* [#6711](https://github.com/netbox-community/netbox/issues/6711) - Add `longtext` custom field type with Markdown support
+* [#6715](https://github.com/netbox-community/netbox/issues/6715) - Add tenant assignment for cables
* [#6874](https://github.com/netbox-community/netbox/issues/6874) - Add tenant assignment for locations
### Other Changes
diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py
index 9d261d9e8..14a1af8f0 100644
--- a/netbox/dcim/api/serializers.py
+++ b/netbox/dcim/api/serializers.py
@@ -758,14 +758,15 @@ class CableSerializer(PrimaryModelSerializer):
termination_a = serializers.SerializerMethodField(read_only=True)
termination_b = serializers.SerializerMethodField(read_only=True)
status = ChoiceField(choices=CableStatusChoices, required=False)
+ tenant = NestedTenantSerializer(required=False, allow_null=True)
length_unit = ChoiceField(choices=CableLengthUnitChoices, allow_blank=True, required=False)
class Meta:
model = Cable
fields = [
'id', 'url', 'display', 'termination_a_type', 'termination_a_id', 'termination_a', 'termination_b_type',
- 'termination_b_id', 'termination_b', 'type', 'status', 'label', 'color', 'length', 'length_unit', 'tags',
- 'custom_fields',
+ 'termination_b_id', 'termination_b', 'type', 'status', 'tenant', 'label', 'color', 'length', 'length_unit',
+ 'tags', 'custom_fields',
]
def _get_termination(self, obj, side):
diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py
index c3de7cb08..c66397029 100644
--- a/netbox/dcim/filtersets.py
+++ b/netbox/dcim/filtersets.py
@@ -1189,7 +1189,7 @@ class VirtualChassisFilterSet(PrimaryModelFilterSet):
return queryset.filter(qs_filter).distinct()
-class CableFilterSet(PrimaryModelFilterSet):
+class CableFilterSet(TenancyFilterSet, PrimaryModelFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
@@ -1230,14 +1230,6 @@ class CableFilterSet(PrimaryModelFilterSet):
method='filter_device',
field_name='device__site__slug'
)
- tenant_id = MultiValueNumberFilter(
- method='filter_device',
- field_name='device__tenant_id'
- )
- tenant = MultiValueNumberFilter(
- method='filter_device',
- field_name='device__tenant__slug'
- )
tag = TagFilter()
class Meta:
diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py
index 289057be9..06ccc958c 100644
--- a/netbox/dcim/forms/bulk_edit.py
+++ b/netbox/dcim/forms/bulk_edit.py
@@ -468,6 +468,10 @@ class CableBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkE
widget=StaticSelect(),
initial=''
)
+ tenant = DynamicModelChoiceField(
+ queryset=Tenant.objects.all(),
+ required=False
+ )
label = forms.CharField(
max_length=100,
required=False
@@ -488,7 +492,7 @@ class CableBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkE
class Meta:
nullable_fields = [
- 'type', 'status', 'label', 'color', 'length',
+ 'type', 'status', 'tenant', 'label', 'color', 'length',
]
def clean(self):
diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py
index bd9e8cd4a..720ea8dbd 100644
--- a/netbox/dcim/forms/bulk_import.py
+++ b/netbox/dcim/forms/bulk_import.py
@@ -821,6 +821,12 @@ class CableCSVForm(CustomFieldModelCSVForm):
required=False,
help_text='Physical medium classification'
)
+ tenant = CSVModelChoiceField(
+ queryset=Tenant.objects.all(),
+ required=False,
+ to_field_name='name',
+ help_text='Assigned tenant'
+ )
length_unit = CSVChoiceField(
choices=CableLengthUnitChoices,
required=False,
@@ -831,7 +837,7 @@ class CableCSVForm(CustomFieldModelCSVForm):
model = Cable
fields = [
'side_a_device', 'side_a_type', 'side_a_name', 'side_b_device', 'side_b_type', 'side_b_name', 'type',
- 'status', 'label', 'color', 'length', 'length_unit',
+ 'status', 'tenant', 'label', 'color', 'length', 'length_unit',
]
help_texts = {
'color': mark_safe('RGB color in hexadecimal (e.g. 00ff00
)'),
diff --git a/netbox/dcim/forms/connections.py b/netbox/dcim/forms/connections.py
index a2ceea6cf..770dc211b 100644
--- a/netbox/dcim/forms/connections.py
+++ b/netbox/dcim/forms/connections.py
@@ -2,6 +2,7 @@ from circuits.models import Circuit, CircuitTermination, Provider
from dcim.models import *
from extras.forms import CustomFieldModelForm
from extras.models import Tag
+from tenancy.forms import TenancyForm
from utilities.forms import BootstrapMixin, DynamicModelChoiceField, DynamicModelMultipleChoiceField, StaticSelect
__all__ = (
@@ -17,7 +18,7 @@ __all__ = (
)
-class ConnectCableToDeviceForm(BootstrapMixin, CustomFieldModelForm):
+class ConnectCableToDeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
"""
Base form for connecting a Cable to a Device component
"""
@@ -78,7 +79,8 @@ class ConnectCableToDeviceForm(BootstrapMixin, CustomFieldModelForm):
model = Cable
fields = [
'termination_b_region', 'termination_b_site', 'termination_b_rack', 'termination_b_device',
- 'termination_b_id', 'type', 'status', 'label', 'color', 'length', 'length_unit', 'tags',
+ 'termination_b_id', 'type', 'status', 'tenant_group', 'tenant', 'label', 'color', 'length', 'length_unit',
+ 'tags',
]
widgets = {
'status': StaticSelect,
@@ -169,7 +171,7 @@ class ConnectCableToRearPortForm(ConnectCableToDeviceForm):
)
-class ConnectCableToCircuitTerminationForm(BootstrapMixin, CustomFieldModelForm):
+class ConnectCableToCircuitTerminationForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
termination_b_provider = DynamicModelChoiceField(
queryset=Provider.objects.all(),
label='Provider',
@@ -219,7 +221,8 @@ class ConnectCableToCircuitTerminationForm(BootstrapMixin, CustomFieldModelForm)
model = Cable
fields = [
'termination_b_provider', 'termination_b_region', 'termination_b_site', 'termination_b_circuit',
- 'termination_b_id', 'type', 'status', 'label', 'color', 'length', 'length_unit', 'tags',
+ 'termination_b_id', 'type', 'status', 'tenant_group', 'tenant', 'label', 'color', 'length', 'length_unit',
+ 'tags',
]
def clean_termination_b_id(self):
@@ -227,7 +230,7 @@ class ConnectCableToCircuitTerminationForm(BootstrapMixin, CustomFieldModelForm)
return getattr(self.cleaned_data['termination_b_id'], 'pk', None)
-class ConnectCableToPowerFeedForm(BootstrapMixin, CustomFieldModelForm):
+class ConnectCableToPowerFeedForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
termination_b_region = DynamicModelChoiceField(
queryset=Region.objects.all(),
label='Region',
@@ -280,8 +283,8 @@ class ConnectCableToPowerFeedForm(BootstrapMixin, CustomFieldModelForm):
class Meta:
model = Cable
fields = [
- 'termination_b_location', 'termination_b_powerpanel', 'termination_b_id', 'type', 'status', 'label',
- 'color', 'length', 'length_unit', 'tags',
+ 'termination_b_location', 'termination_b_powerpanel', 'termination_b_id', 'type', 'status', 'tenant_group',
+ 'tenant', 'label', 'color', 'length', 'length_unit', 'tags',
]
def clean_termination_b_id(self):
diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py
index 94e7bce05..501e78b18 100644
--- a/netbox/dcim/forms/filtersets.py
+++ b/netbox/dcim/forms/filtersets.py
@@ -691,13 +691,13 @@ class VirtualChassisFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldMod
tag = TagFilterField(model)
-class CableFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
+class CableFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
model = Cable
field_groups = [
['q', 'tag'],
['site_id', 'rack_id', 'device_id'],
['type', 'status', 'color'],
- ['tenant_id'],
+ ['tenant_group_id', 'tenant_id'],
]
q = forms.CharField(
required=False,
@@ -719,12 +719,6 @@ class CableFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
label=_('Site'),
fetch_trigger='open'
)
- tenant_id = DynamicModelMultipleChoiceField(
- queryset=Tenant.objects.all(),
- required=False,
- label=_('Tenant'),
- fetch_trigger='open'
- )
rack_id = DynamicModelMultipleChoiceField(
queryset=Rack.objects.all(),
required=False,
diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py
index cb690840f..8236b1a97 100644
--- a/netbox/dcim/forms/models.py
+++ b/netbox/dcim/forms/models.py
@@ -601,7 +601,7 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
self.fields['position'].widget.choices = [(position, f'U{position}')]
-class CableForm(BootstrapMixin, CustomFieldModelForm):
+class CableForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
@@ -610,7 +610,7 @@ class CableForm(BootstrapMixin, CustomFieldModelForm):
class Meta:
model = Cable
fields = [
- 'type', 'status', 'label', 'color', 'length', 'length_unit', 'tags',
+ 'type', 'status', 'tenant_group', 'tenant', 'label', 'color', 'length', 'length_unit', 'tags',
]
widgets = {
'status': StaticSelect,
diff --git a/netbox/dcim/migrations/0135_location_tenant.py b/netbox/dcim/migrations/0135_tenancy_extensions.py
similarity index 67%
rename from netbox/dcim/migrations/0135_location_tenant.py
rename to netbox/dcim/migrations/0135_tenancy_extensions.py
index 0b1f429f9..673b5027f 100644
--- a/netbox/dcim/migrations/0135_location_tenant.py
+++ b/netbox/dcim/migrations/0135_tenancy_extensions.py
@@ -15,4 +15,9 @@ class Migration(migrations.Migration):
name='tenant',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='locations', to='tenancy.tenant'),
),
+ migrations.AddField(
+ model_name='cable',
+ name='tenant',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='cables', to='tenancy.tenant'),
+ ),
]
diff --git a/netbox/dcim/migrations/0136_device_airflow.py b/netbox/dcim/migrations/0136_device_airflow.py
index a0887a0b4..94cc89f3f 100644
--- a/netbox/dcim/migrations/0136_device_airflow.py
+++ b/netbox/dcim/migrations/0136_device_airflow.py
@@ -4,7 +4,7 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
- ('dcim', '0135_location_tenant'),
+ ('dcim', '0135_tenancy_extensions'),
]
operations = [
diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py
index c3f8cac3f..bddce93b9 100644
--- a/netbox/dcim/models/cables.py
+++ b/netbox/dcim/models/cables.py
@@ -67,6 +67,13 @@ class Cable(PrimaryModel):
choices=CableStatusChoices,
default=CableStatusChoices.STATUS_CONNECTED
)
+ tenant = models.ForeignKey(
+ to='tenancy.Tenant',
+ on_delete=models.PROTECT,
+ related_name='cables',
+ blank=True,
+ null=True
+ )
label = models.CharField(
max_length=100,
blank=True
diff --git a/netbox/dcim/tables/cables.py b/netbox/dcim/tables/cables.py
index 14cf34505..87913cbfd 100644
--- a/netbox/dcim/tables/cables.py
+++ b/netbox/dcim/tables/cables.py
@@ -2,6 +2,7 @@ import django_tables2 as tables
from django_tables2.utils import Accessor
from dcim.models import Cable
+from tenancy.tables import TenantColumn
from utilities.tables import BaseTable, ChoiceFieldColumn, ColorColumn, TagColumn, TemplateColumn, ToggleColumn
from .template_code import CABLE_LENGTH, CABLE_TERMINATION_PARENT
@@ -45,6 +46,7 @@ class CableTable(BaseTable):
verbose_name='Termination B'
)
status = ChoiceFieldColumn()
+ tenant = TenantColumn()
length = TemplateColumn(
template_code=CABLE_LENGTH,
order_by='_abs_length'
@@ -58,7 +60,7 @@ class CableTable(BaseTable):
model = Cable
fields = (
'pk', 'id', 'label', 'termination_a_parent', 'termination_a', 'termination_b_parent', 'termination_b',
- 'status', 'type', 'color', 'length', 'tags',
+ 'status', 'type', 'tenant', 'color', 'length', 'tags',
)
default_columns = (
'pk', 'id', 'label', 'termination_a_parent', 'termination_a', 'termination_b_parent', 'termination_b',
diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py
index fcee2914b..ce78e0470 100644
--- a/netbox/dcim/tests/test_filtersets.py
+++ b/netbox/dcim/tests/test_filtersets.py
@@ -2819,6 +2819,7 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
tenants = (
Tenant(name='Tenant 1', slug='tenant-1'),
Tenant(name='Tenant 2', slug='tenant-2'),
+ Tenant(name='Tenant 3', slug='tenant-3'),
)
Tenant.objects.bulk_create(tenants)
@@ -2834,9 +2835,9 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
devices = (
- Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], rack=racks[0], position=1, tenant=tenants[0]),
- Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[0], rack=racks[0], position=2, tenant=tenants[0]),
- Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[1], rack=racks[1], position=1, tenant=tenants[1]),
+ Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], rack=racks[0], position=1),
+ Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[0], rack=racks[0], position=2),
+ Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[1], rack=racks[1], position=1),
Device(name='Device 4', device_type=device_type, device_role=device_role, site=sites[1], rack=racks[1], position=2),
Device(name='Device 5', device_type=device_type, device_role=device_role, site=sites[2], rack=racks[2], position=1),
Device(name='Device 6', device_type=device_type, device_role=device_role, site=sites[2], rack=racks[2], position=2),
@@ -2863,12 +2864,12 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
console_server_port = ConsoleServerPort.objects.create(device=devices[0], name='Console Server Port 1')
# Cables
- Cable(termination_a=interfaces[1], termination_b=interfaces[2], label='Cable 1', type=CableTypeChoices.TYPE_CAT3, status=CableStatusChoices.STATUS_CONNECTED, color='aa1409', length=10, length_unit=CableLengthUnitChoices.UNIT_FOOT).save()
- Cable(termination_a=interfaces[3], termination_b=interfaces[4], label='Cable 2', type=CableTypeChoices.TYPE_CAT3, status=CableStatusChoices.STATUS_CONNECTED, color='aa1409', length=20, length_unit=CableLengthUnitChoices.UNIT_FOOT).save()
- Cable(termination_a=interfaces[5], termination_b=interfaces[6], label='Cable 3', type=CableTypeChoices.TYPE_CAT5E, status=CableStatusChoices.STATUS_CONNECTED, color='f44336', length=30, length_unit=CableLengthUnitChoices.UNIT_FOOT).save()
- Cable(termination_a=interfaces[7], termination_b=interfaces[8], label='Cable 4', type=CableTypeChoices.TYPE_CAT5E, status=CableStatusChoices.STATUS_PLANNED, color='f44336', length=40, length_unit=CableLengthUnitChoices.UNIT_FOOT).save()
- Cable(termination_a=interfaces[9], termination_b=interfaces[10], label='Cable 5', type=CableTypeChoices.TYPE_CAT6, status=CableStatusChoices.STATUS_PLANNED, color='e91e63', length=10, length_unit=CableLengthUnitChoices.UNIT_METER).save()
- Cable(termination_a=interfaces[11], termination_b=interfaces[0], label='Cable 6', type=CableTypeChoices.TYPE_CAT6, status=CableStatusChoices.STATUS_PLANNED, color='e91e63', length=20, length_unit=CableLengthUnitChoices.UNIT_METER).save()
+ Cable(termination_a=interfaces[1], termination_b=interfaces[2], label='Cable 1', type=CableTypeChoices.TYPE_CAT3, tenant=tenants[0], status=CableStatusChoices.STATUS_CONNECTED, color='aa1409', length=10, length_unit=CableLengthUnitChoices.UNIT_FOOT).save()
+ Cable(termination_a=interfaces[3], termination_b=interfaces[4], label='Cable 2', type=CableTypeChoices.TYPE_CAT3, tenant=tenants[0], status=CableStatusChoices.STATUS_CONNECTED, color='aa1409', length=20, length_unit=CableLengthUnitChoices.UNIT_FOOT).save()
+ Cable(termination_a=interfaces[5], termination_b=interfaces[6], label='Cable 3', type=CableTypeChoices.TYPE_CAT5E, tenant=tenants[1], status=CableStatusChoices.STATUS_CONNECTED, color='f44336', length=30, length_unit=CableLengthUnitChoices.UNIT_FOOT).save()
+ Cable(termination_a=interfaces[7], termination_b=interfaces[8], label='Cable 4', type=CableTypeChoices.TYPE_CAT5E, tenant=tenants[1], status=CableStatusChoices.STATUS_PLANNED, color='f44336', length=40, length_unit=CableLengthUnitChoices.UNIT_FOOT).save()
+ Cable(termination_a=interfaces[9], termination_b=interfaces[10], label='Cable 5', type=CableTypeChoices.TYPE_CAT6, tenant=tenants[2], status=CableStatusChoices.STATUS_PLANNED, color='e91e63', length=10, length_unit=CableLengthUnitChoices.UNIT_METER).save()
+ Cable(termination_a=interfaces[11], termination_b=interfaces[0], label='Cable 6', type=CableTypeChoices.TYPE_CAT6, tenant=tenants[2], status=CableStatusChoices.STATUS_PLANNED, color='e91e63', length=20, length_unit=CableLengthUnitChoices.UNIT_METER).save()
Cable(termination_a=console_port, termination_b=console_server_port, label='Cable 7').save()
def test_label(self):
@@ -2921,9 +2922,9 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
def test_tenant(self):
tenant = Tenant.objects.all()[:2]
params = {'tenant_id': [tenant[0].pk, tenant[1].pk]}
- self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'tenant': [tenant[0].slug, tenant[1].slug]}
- self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_termination_types(self):
params = {'termination_a_type': 'dcim.consoleport'}
diff --git a/netbox/templates/dcim/cable.html b/netbox/templates/dcim/cable.html
index c7cd71b65..e9cde6e00 100644
--- a/netbox/templates/dcim/cable.html
+++ b/netbox/templates/dcim/cable.html
@@ -23,6 +23,19 @@
{{ object.get_status_display }}
+