Repository: netbox-community/netbox

Issue:
Add filter to select cables by termination sites
### Environment
* Python version:  2.7.5
* NetBox version:  2.5.13

### Proposed Functionality
Would like to have the ability to list cables filtered by the site assigned to either or both of the termination endpoints.

### Use Case
The goal is to be able to pull up inventories of all the equipment associated with a particular site. Currently there is no straightforward/convenient way to do this for cables.

### Database Changes
None that I would expect.

### External Dependencies
None.


Discussion:
The cables UI is not scalable. Consider 15 sites and 300 cables each. That's 4500 cables presented as pages in the UI. Because there's no substring matching for device name, one cannot use naming conventions to help filter down to a specific site.

I also find it odd that devices are listed in the "Stats" box for a site, but cables are not. If this feature is implemented, that could be rectified with a simple link with a "?site=foo" filter in the URL.
If adding filter cables by site, can we have filter by rack as well please!

Resolving patch:
diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py
--- a/netbox/dcim/filters.py
+++ b/netbox/dcim/filters.py
@@ -1,6 +1,5 @@
 import django_filters
 from django.contrib.auth.models import User
-from django.core.exceptions import ObjectDoesNotExist
 from django.db.models import Q
 
 from extras.filters import CustomFieldFilterSet, LocalConfigContextFilter
@@ -931,13 +930,28 @@ class CableFilter(django_filters.FilterSet):
     color = django_filters.MultipleChoiceFilter(
         choices=COLOR_CHOICES
     )
-    device = django_filters.CharFilter(
-        method='filter_connected_device',
-        field_name='name'
+    device_id = MultiValueNumberFilter(
+        method='filter_device'
+    )
+    device = MultiValueNumberFilter(
+        method='filter_device',
+        field_name='device__name'
+    )
+    rack_id = MultiValueNumberFilter(
+        method='filter_device',
+        field_name='device__rack_id'
+    )
+    rack = MultiValueNumberFilter(
+        method='filter_device',
+        field_name='device__rack__name'
+    )
+    site_id = MultiValueNumberFilter(
+        method='filter_device',
+        field_name='device__site_id'
     )
-    device_id = django_filters.CharFilter(
-        method='filter_connected_device',
-        field_name='pk'
+    site = MultiValueNumberFilter(
+        method='filter_device',
+        field_name='device__site__slug'
     )
 
     class Meta:
@@ -949,15 +963,12 @@ def search(self, queryset, name, value):
             return queryset
         return queryset.filter(label__icontains=value)
 
-    def filter_connected_device(self, queryset, name, value):
-        if not value.strip():
-            return queryset
-        try:
-            device = Device.objects.get(**{name: value})
-        except ObjectDoesNotExist:
-            return queryset.none()
-        cable_pks = device.get_cables(pk_list=True)
-        return queryset.filter(pk__in=cable_pks)
+    def filter_device(self, queryset, name, value):
+        queryset = queryset.filter(
+            Q(**{'_termination_a_{}__in'.format(name): value}) |
+            Q(**{'_termination_b_{}__in'.format(name): value})
+        )
+        return queryset
 
 
 class ConsoleConnectionFilter(django_filters.FilterSet):
diff --git a/netbox/dcim/migrations/0075_cable_devices.py b/netbox/dcim/migrations/0075_cable_devices.py
new file mode 100644
--- /dev/null
+++ b/netbox/dcim/migrations/0075_cable_devices.py
@@ -0,0 +1,46 @@
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+def cache_cable_devices(apps, schema_editor):
+    Cable = apps.get_model('dcim', 'Cable')
+
+    # Cache A/B termination devices on all existing Cables. Note that the custom save() method on Cable is not
+    # available during a migration, so we replicate its logic here.
+    for cable in Cable.objects.all():
+
+        termination_a_model = apps.get_model(cable.termination_a_type.app_label, cable.termination_a_type.model)
+        if hasattr(termination_a_model, 'device'):
+            termination_a = termination_a_model.objects.get(pk=cable.termination_a_id)
+            cable._termination_a_device = termination_a.device
+
+        termination_b_model = apps.get_model(cable.termination_b_type.app_label, cable.termination_b_type.model)
+        if hasattr(termination_b_model, 'device'):
+            termination_b = termination_b_model.objects.get(pk=cable.termination_b_id)
+            cable._termination_b_device = termination_b.device
+
+        cable.save()
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0074_increase_field_length_platform_name_slug'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='cable',
+            name='_termination_a_device',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='dcim.Device'),
+        ),
+        migrations.AddField(
+            model_name='cable',
+            name='_termination_b_device',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='dcim.Device'),
+        ),
+        migrations.RunPython(
+            code=cache_cable_devices,
+            reverse_code=migrations.RunPython.noop
+        ),
+    ]
diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py
--- a/netbox/dcim/models.py
+++ b/netbox/dcim/models.py
@@ -2781,6 +2781,22 @@ class Cable(ChangeLoggedModel):
         blank=True,
         null=True
     )
+    # Cache the associated device (where applicable) for the A and B terminations. This enables filtering of Cables by
+    # their associated Devices.
+    _termination_a_device = models.ForeignKey(
+        to=Device,
+        on_delete=models.CASCADE,
+        related_name='+',
+        blank=True,
+        null=True
+    )
+    _termination_b_device = models.ForeignKey(
+        to=Device,
+        on_delete=models.CASCADE,
+        related_name='+',
+        blank=True,
+        null=True
+    )
 
     csv_headers = [
         'termination_a_type', 'termination_a_id', 'termination_b_type', 'termination_b_id', 'type', 'status', 'label',
@@ -2895,6 +2911,12 @@ def save(self, *args, **kwargs):
         if self.length and self.length_unit:
             self._abs_length = to_meters(self.length, self.length_unit)
 
+        # Store the parent Device for the A and B terminations (if applicable) to enable filtering
+        if hasattr(self.termination_a, 'device'):
+            self._termination_a_device = self.termination_a.device
+        if hasattr(self.termination_b, 'device'):
+            self._termination_b_device = self.termination_b.device
+
         super().save(*args, **kwargs)
 
     def to_csv(self):


Test patch: