Просмотр исходного кода

Merge pull request #2838 from digitalocean/develop

Release v2.5.5
Jeremy Stretch 7 лет назад
Родитель
Сommit
d5fc37282f

+ 17 - 0
CHANGELOG.md

@@ -1,3 +1,20 @@
+v2.5.5 (2019-01-31)
+
+## Enhancements
+
+* [#2805](https://github.com/digitalocean/netbox/issues/2805) - Allow null route distinguisher for VRFs
+* [#2809](https://github.com/digitalocean/netbox/issues/2809) - Remove VRF child prefixes table; link to main prefixes view
+* [#2825](https://github.com/digitalocean/netbox/issues/2825) - Include directly connected device for front/rear ports
+
+## Bug Fixes
+
+* [#2824](https://github.com/digitalocean/netbox/issues/2824) - Fix template exception when viewing rack elevations list
+* [#2833](https://github.com/digitalocean/netbox/issues/2833) - Fix form widget for front port template creation
+* [#2835](https://github.com/digitalocean/netbox/issues/2835) - Fix certain model filters did not support the `q` query param
+* [#2837](https://github.com/digitalocean/netbox/issues/2837) - Fix select2 nullable filter fields add multiple null_option elements when paging
+
+---
+
 v2.5.4 (2019-01-29)
 v2.5.4 (2019-01-29)
 
 
 ## Enhancements
 ## Enhancements

+ 1 - 1
README.md

@@ -37,7 +37,7 @@ and run `upgrade.sh`.
 
 
 ## Alternative Installations
 ## Alternative Installations
 
 
-* [Docker container](https://github.com/ninech/netbox-docker) (via [@cimnine](https://github.com/cimnine))
+* [Docker container](https://github.com/netbox-community/netbox-docker) (via [@cimnine](https://github.com/cimnine))
 * [Vagrant deployment](https://github.com/ryanmerolle/netbox-vagrant) (via [@ryanmerolle](https://github.com/ryanmerolle))
 * [Vagrant deployment](https://github.com/ryanmerolle/netbox-vagrant) (via [@ryanmerolle](https://github.com/ryanmerolle))
 * [Ansible deployment](https://github.com/lae/ansible-role-netbox) (via [@lae](https://github.com/lae))
 * [Ansible deployment](https://github.com/lae/ansible-role-netbox) (via [@lae](https://github.com/lae))
 
 

+ 1 - 1
docs/additional-features/tags.md

@@ -1,6 +1,6 @@
 # Tags
 # Tags
 
 
-Tags are free-form text labels which can be applied to a variety of objects within NetBox. Tags are created on-demand as they are assigned to objects. Use commas to separate tags when adding multiple tags to an object 9for example: `Inventoried, Monitored`). Use double quotes around a multi-word tag when adding only one tag, e.g. `"Core Switch"`.
+Tags are free-form text labels which can be applied to a variety of objects within NetBox. Tags are created on-demand as they are assigned to objects. Use commas to separate tags when adding multiple tags to an object (for example: `Inventoried, Monitored`). Use double quotes around a multi-word tag when adding only one tag, e.g. `"Core Switch"`.
 
 
 Each tag has a label and a URL-friendly slug. For example, the slug for a tag named "Dunder Mifflin, Inc." would be `dunder-mifflin-inc`. The slug is generated automatically and makes tags easier to work with as URL parameters.
 Each tag has a label and a URL-friendly slug. For example, the slug for a tag named "Dunder Mifflin, Inc." would be `dunder-mifflin-inc`. The slug is generated automatically and makes tags easier to work with as URL parameters.
 
 

+ 1 - 1
docs/core-functionality/ipam.md

@@ -83,7 +83,7 @@ An IP address can be designated as the network address translation (NAT) inside
 
 
 A VRF object in NetBox represents a virtual routing and forwarding (VRF) domain. Each VRF is essentially a separate routing table. VRFs are commonly used to isolate customers or organizations from one another within a network, or to route overlapping address space (e.g. multiple instances of the 10.0.0.0/8 space).
 A VRF object in NetBox represents a virtual routing and forwarding (VRF) domain. Each VRF is essentially a separate routing table. VRFs are commonly used to isolate customers or organizations from one another within a network, or to route overlapping address space (e.g. multiple instances of the 10.0.0.0/8 space).
 
 
-Each VRF is assigned a unique name and route distinguisher (RD). The RD is expected to take one of the forms prescribed in [RFC 4364](https://tools.ietf.org/html/rfc4364#section-4.2), however its formatting is not strictly enforced.
+Each VRF is assigned a unique name and an optional route distinguisher (RD). The RD is expected to take one of the forms prescribed in [RFC 4364](https://tools.ietf.org/html/rfc4364#section-4.2), however its formatting is not strictly enforced.
 
 
 Each prefix and IP address may be assigned to one (and only one) VRF. If you have a prefix or IP address which exists in multiple VRFs, you will need to create a separate instance of it in NetBox for each VRF. Any IP prefix or address not assigned to a VRF is said to belong to the "global" table.
 Each prefix and IP address may be assigned to one (and only one) VRF. If you have a prefix or IP address which exists in multiple VRFs, you will need to create a separate instance of it in NetBox for each VRF. Any IP prefix or address not assigned to a VRF is said to belong to the "global" table.
 
 

+ 2 - 2
netbox/circuits/filters.py

@@ -4,7 +4,7 @@ from django.db.models import Q
 from dcim.models import Site
 from dcim.models import Site
 from extras.filters import CustomFieldFilterSet
 from extras.filters import CustomFieldFilterSet
 from tenancy.models import Tenant
 from tenancy.models import Tenant
-from utilities.filters import NumericInFilter, TagFilter
+from utilities.filters import NameSlugSearchFilterSet, NumericInFilter, TagFilter
 from .constants import CIRCUIT_STATUS_CHOICES
 from .constants import CIRCUIT_STATUS_CHOICES
 from .models import Provider, Circuit, CircuitTermination, CircuitType
 from .models import Provider, Circuit, CircuitTermination, CircuitType
 
 
@@ -47,7 +47,7 @@ class ProviderFilter(CustomFieldFilterSet, django_filters.FilterSet):
         )
         )
 
 
 
 
-class CircuitTypeFilter(django_filters.FilterSet):
+class CircuitTypeFilter(NameSlugSearchFilterSet):
 
 
     class Meta:
     class Meta:
         model = CircuitType
         model = CircuitType

+ 5 - 1
netbox/circuits/models.py

@@ -3,7 +3,7 @@ from django.db import models
 from django.urls import reverse
 from django.urls import reverse
 from taggit.managers import TaggableManager
 from taggit.managers import TaggableManager
 
 
-from dcim.constants import CONNECTION_STATUS_CHOICES, CONNECTION_STATUS_CONNECTED, STATUS_CLASSES
+from dcim.constants import CONNECTION_STATUS_CHOICES, STATUS_CLASSES
 from dcim.fields import ASNField
 from dcim.fields import ASNField
 from dcim.models import CableTermination
 from dcim.models import CableTermination
 from extras.models import CustomFieldModel, ObjectChange
 from extras.models import CustomFieldModel, ObjectChange
@@ -283,6 +283,10 @@ class CircuitTermination(CableTermination):
             object_data=serialize_object(self)
             object_data=serialize_object(self)
         ).save()
         ).save()
 
 
+    @property
+    def parent(self):
+        return self.circuit
+
     def get_peer_termination(self):
     def get_peer_termination(self):
         peer_side = 'Z' if self.term_side == 'A' else 'A'
         peer_side = 'Z' if self.term_side == 'A' else 'A'
         try:
         try:

+ 19 - 34
netbox/dcim/filters.py

@@ -8,7 +8,7 @@ from netaddr.core import AddrFormatError
 from extras.filters import CustomFieldFilterSet
 from extras.filters import CustomFieldFilterSet
 from tenancy.models import Tenant
 from tenancy.models import Tenant
 from utilities.constants import COLOR_CHOICES
 from utilities.constants import COLOR_CHOICES
-from utilities.filters import NullableCharFieldFilter, NumericInFilter, TagFilter
+from utilities.filters import NameSlugSearchFilterSet, NullableCharFieldFilter, NumericInFilter, TagFilter
 from virtualization.models import Cluster
 from virtualization.models import Cluster
 from .constants import *
 from .constants import *
 from .models import (
 from .models import (
@@ -19,11 +19,7 @@ from .models import (
 )
 )
 
 
 
 
-class RegionFilter(django_filters.FilterSet):
-    q = django_filters.CharFilter(
-        method='search',
-        label='Search',
-    )
+class RegionFilter(NameSlugSearchFilterSet):
     parent_id = django_filters.ModelMultipleChoiceFilter(
     parent_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
         label='Parent region (ID)',
         label='Parent region (ID)',
@@ -39,15 +35,6 @@ class RegionFilter(django_filters.FilterSet):
         model = Region
         model = Region
         fields = ['name', 'slug']
         fields = ['name', 'slug']
 
 
-    def search(self, queryset, name, value):
-        if not value.strip():
-            return queryset
-        qs_filter = (
-            Q(name__icontains=value) |
-            Q(slug__icontains=value)
-        )
-        return queryset.filter(qs_filter)
-
 
 
 class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet):
 class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet):
     id__in = NumericInFilter(
     id__in = NumericInFilter(
@@ -119,11 +106,7 @@ class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet):
         )
         )
 
 
 
 
-class RackGroupFilter(django_filters.FilterSet):
-    q = django_filters.CharFilter(
-        method='search',
-        label='Search',
-    )
+class RackGroupFilter(NameSlugSearchFilterSet):
     site_id = django_filters.ModelMultipleChoiceFilter(
     site_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         label='Site (ID)',
         label='Site (ID)',
@@ -139,17 +122,8 @@ class RackGroupFilter(django_filters.FilterSet):
         model = RackGroup
         model = RackGroup
         fields = ['site_id', 'name', 'slug']
         fields = ['site_id', 'name', 'slug']
 
 
-    def search(self, queryset, name, value):
-        if not value.strip():
-            return queryset
-        qs_filter = (
-            Q(name__icontains=value) |
-            Q(slug__icontains=value)
-        )
-        return queryset.filter(qs_filter)
 
 
-
-class RackRoleFilter(django_filters.FilterSet):
+class RackRoleFilter(NameSlugSearchFilterSet):
 
 
     class Meta:
     class Meta:
         model = RackRole
         model = RackRole
@@ -303,7 +277,7 @@ class RackReservationFilter(django_filters.FilterSet):
         )
         )
 
 
 
 
-class ManufacturerFilter(django_filters.FilterSet):
+class ManufacturerFilter(NameSlugSearchFilterSet):
 
 
     class Meta:
     class Meta:
         model = Manufacturer
         model = Manufacturer
@@ -393,7 +367,7 @@ class DeviceTypeFilter(CustomFieldFilterSet):
         )
         )
 
 
 
 
-class DeviceTypeComponentFilterSet(django_filters.FilterSet):
+class DeviceTypeComponentFilterSet(NameSlugSearchFilterSet):
     devicetype_id = django_filters.ModelMultipleChoiceFilter(
     devicetype_id = django_filters.ModelMultipleChoiceFilter(
         queryset=DeviceType.objects.all(),
         queryset=DeviceType.objects.all(),
         field_name='device_type_id',
         field_name='device_type_id',
@@ -457,14 +431,14 @@ class DeviceBayTemplateFilter(DeviceTypeComponentFilterSet):
         fields = ['name']
         fields = ['name']
 
 
 
 
-class DeviceRoleFilter(django_filters.FilterSet):
+class DeviceRoleFilter(NameSlugSearchFilterSet):
 
 
     class Meta:
     class Meta:
         model = DeviceRole
         model = DeviceRole
         fields = ['name', 'slug', 'color', 'vm_role']
         fields = ['name', 'slug', 'color', 'vm_role']
 
 
 
 
-class PlatformFilter(django_filters.FilterSet):
+class PlatformFilter(NameSlugSearchFilterSet):
     manufacturer_id = django_filters.ModelMultipleChoiceFilter(
     manufacturer_id = django_filters.ModelMultipleChoiceFilter(
         field_name='manufacturer',
         field_name='manufacturer',
         queryset=Manufacturer.objects.all(),
         queryset=Manufacturer.objects.all(),
@@ -696,6 +670,10 @@ class DeviceFilter(CustomFieldFilterSet):
 
 
 
 
 class DeviceComponentFilterSet(django_filters.FilterSet):
 class DeviceComponentFilterSet(django_filters.FilterSet):
+    q = django_filters.CharFilter(
+        method='search',
+        label='Search',
+    )
     device_id = django_filters.ModelChoiceFilter(
     device_id = django_filters.ModelChoiceFilter(
         queryset=Device.objects.all(),
         queryset=Device.objects.all(),
         label='Device (ID)',
         label='Device (ID)',
@@ -707,6 +685,13 @@ class DeviceComponentFilterSet(django_filters.FilterSet):
     )
     )
     tag = TagFilter()
     tag = TagFilter()
 
 
+    def search(self, queryset, name, value):
+        if not value.strip():
+            return queryset
+        return queryset.filter(
+            Q(name__icontains=value)
+        )
+
 
 
 class ConsolePortFilter(DeviceComponentFilterSet):
 class ConsolePortFilter(DeviceComponentFilterSet):
     cabled = django_filters.BooleanFilter(
     cabled = django_filters.BooleanFilter(

+ 0 - 1
netbox/dcim/forms.py

@@ -1066,7 +1066,6 @@ class FrontPortTemplateCreateForm(ComponentForm):
         choices=[],
         choices=[],
         label='Rear ports',
         label='Rear ports',
         help_text='Select one rear port assignment for each front port being created.',
         help_text='Select one rear port assignment for each front port being created.',
-        widget=StaticSelect2(),
     )
     )
 
 
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):

+ 12 - 0
netbox/dcim/models.py

@@ -68,6 +68,10 @@ class ComponentModel(models.Model):
             object_data=serialize_object(self)
             object_data=serialize_object(self)
         ).save()
         ).save()
 
 
+    @property
+    def parent(self):
+        return getattr(self, 'device', None)
+
 
 
 class CableTermination(models.Model):
 class CableTermination(models.Model):
     cable = models.ForeignKey(
     cable = models.ForeignKey(
@@ -162,6 +166,14 @@ class CableTermination(models.Model):
 
 
         return path + next_segment
         return path + next_segment
 
 
+    def get_cable_peer(self):
+        if self.cable is None:
+            return None
+        if self._cabled_as_a:
+            return self.cable.termination_b
+        if self._cabled_as_b:
+            return self.cable.termination_a
+
 
 
 #
 #
 # Regions
 # Regions

+ 8 - 4
netbox/ipam/filters.py

@@ -7,7 +7,7 @@ from netaddr.core import AddrFormatError
 from dcim.models import Site, Device, Interface
 from dcim.models import Site, Device, Interface
 from extras.filters import CustomFieldFilterSet
 from extras.filters import CustomFieldFilterSet
 from tenancy.models import Tenant
 from tenancy.models import Tenant
-from utilities.filters import NumericInFilter, TagFilter
+from utilities.filters import NameSlugSearchFilterSet, NumericInFilter, TagFilter
 from virtualization.models import VirtualMachine
 from virtualization.models import VirtualMachine
 from .constants import IPADDRESS_ROLE_CHOICES, IPADDRESS_STATUS_CHOICES, PREFIX_STATUS_CHOICES, VLAN_STATUS_CHOICES
 from .constants import IPADDRESS_ROLE_CHOICES, IPADDRESS_STATUS_CHOICES, PREFIX_STATUS_CHOICES, VLAN_STATUS_CHOICES
 from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
 from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
@@ -48,7 +48,7 @@ class VRFFilter(CustomFieldFilterSet, django_filters.FilterSet):
         fields = ['name', 'rd', 'enforce_unique']
         fields = ['name', 'rd', 'enforce_unique']
 
 
 
 
-class RIRFilter(django_filters.FilterSet):
+class RIRFilter(NameSlugSearchFilterSet):
     id__in = NumericInFilter(
     id__in = NumericInFilter(
         field_name='id',
         field_name='id',
         lookup_expr='in'
         lookup_expr='in'
@@ -96,7 +96,11 @@ class AggregateFilter(CustomFieldFilterSet, django_filters.FilterSet):
         return queryset.filter(qs_filter)
         return queryset.filter(qs_filter)
 
 
 
 
-class RoleFilter(django_filters.FilterSet):
+class RoleFilter(NameSlugSearchFilterSet):
+    q = django_filters.CharFilter(
+        method='search',
+        label='Search',
+    )
 
 
     class Meta:
     class Meta:
         model = Role
         model = Role
@@ -373,7 +377,7 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet):
             return queryset.none()
             return queryset.none()
 
 
 
 
-class VLANGroupFilter(django_filters.FilterSet):
+class VLANGroupFilter(NameSlugSearchFilterSet):
     site_id = django_filters.ModelMultipleChoiceFilter(
     site_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         label='Site (ID)',
         label='Site (ID)',

+ 18 - 0
netbox/ipam/migrations/0024_vrf_allow_null_rd.py

@@ -0,0 +1,18 @@
+# Generated by Django 2.1.5 on 2019-01-31 18:14
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('ipam', '0023_change_logging'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='vrf',
+            name='rd',
+            field=models.CharField(blank=True, max_length=21, null=True, unique=True),
+        ),
+    ]

+ 2 - 0
netbox/ipam/models.py

@@ -29,6 +29,8 @@ class VRF(ChangeLoggedModel, CustomFieldModel):
     rd = models.CharField(
     rd = models.CharField(
         max_length=21,
         max_length=21,
         unique=True,
         unique=True,
+        blank=True,
+        null=True,
         verbose_name='Route distinguisher'
         verbose_name='Route distinguisher'
     )
     )
     tenant = models.ForeignKey(
     tenant = models.ForeignKey(

+ 18 - 11
netbox/ipam/tests/test_api.py

@@ -16,7 +16,7 @@ class VRFTest(APITestCase):
 
 
         self.vrf1 = VRF.objects.create(name='Test VRF 1', rd='65000:1')
         self.vrf1 = VRF.objects.create(name='Test VRF 1', rd='65000:1')
         self.vrf2 = VRF.objects.create(name='Test VRF 2', rd='65000:2')
         self.vrf2 = VRF.objects.create(name='Test VRF 2', rd='65000:2')
-        self.vrf3 = VRF.objects.create(name='Test VRF 3', rd='65000:3')
+        self.vrf3 = VRF.objects.create(name='Test VRF 3')  # No RD
 
 
     def test_get_vrf(self):
     def test_get_vrf(self):
 
 
@@ -44,19 +44,26 @@ class VRFTest(APITestCase):
 
 
     def test_create_vrf(self):
     def test_create_vrf(self):
 
 
-        data = {
-            'name': 'Test VRF 4',
-            'rd': '65000:4',
-        }
+        data_list = [
+            # VRF with RD
+            {
+                'name': 'Test VRF 4',
+                'rd': '65000:4',
+            },
+            # VRF without RD
+            {
+                'name': 'Test VRF 5',
+            }
+        ]
 
 
         url = reverse('ipam-api:vrf-list')
         url = reverse('ipam-api:vrf-list')
-        response = self.client.post(url, data, format='json', **self.header)
 
 
-        self.assertHttpStatus(response, status.HTTP_201_CREATED)
-        self.assertEqual(VRF.objects.count(), 4)
-        vrf4 = VRF.objects.get(pk=response.data['id'])
-        self.assertEqual(vrf4.name, data['name'])
-        self.assertEqual(vrf4.rd, data['rd'])
+        for data in data_list:
+            response = self.client.post(url, data, format='json', **self.header)
+            self.assertHttpStatus(response, status.HTTP_201_CREATED)
+            vrf = VRF.objects.get(pk=response.data['id'])
+            self.assertEqual(vrf.name, data['name'])
+            self.assertEqual(vrf.rd, data['rd'] if 'rd' in data else None)
 
 
     def test_create_vrf_bulk(self):
     def test_create_vrf_bulk(self):
 
 

+ 2 - 5
netbox/ipam/views.py

@@ -126,14 +126,11 @@ class VRFView(View):
     def get(self, request, pk):
     def get(self, request, pk):
 
 
         vrf = get_object_or_404(VRF.objects.all(), pk=pk)
         vrf = get_object_or_404(VRF.objects.all(), pk=pk)
-        prefix_table = tables.PrefixTable(
-            list(Prefix.objects.filter(vrf=vrf).select_related('site', 'role')), orderable=False
-        )
-        prefix_table.exclude = ('vrf',)
+        prefix_count = Prefix.objects.filter(vrf=vrf).count()
 
 
         return render(request, 'ipam/vrf.html', {
         return render(request, 'ipam/vrf.html', {
             'vrf': vrf,
             'vrf': vrf,
-            'prefix_table': prefix_table,
+            'prefix_count': prefix_count,
         })
         })
 
 
 
 

+ 1 - 1
netbox/netbox/settings.py

@@ -22,7 +22,7 @@ except ImportError:
     )
     )
 
 
 
 
-VERSION = '2.5.4'
+VERSION = '2.5.5'
 
 
 BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
 BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
 
 

+ 2 - 2
netbox/project-static/js/forms.js

@@ -197,8 +197,8 @@ $(document).ready(function() {
                     return obj;
                     return obj;
                 });
                 });
 
 
-                // Handle the null option
-                if (element.getAttribute('data-null-option')) {
+                // Handle the null option, but only add it once
+                if (element.getAttribute('data-null-option') && data.previous === null) {
                     var null_option = $(element).children()[0]
                     var null_option = $(element).children()[0]
                     results.unshift({
                     results.unshift({
                         id: null_option.value,
                         id: null_option.value,

+ 2 - 2
netbox/secrets/filters.py

@@ -3,11 +3,11 @@ from django.db.models import Q
 
 
 from dcim.models import Device
 from dcim.models import Device
 from extras.filters import CustomFieldFilterSet
 from extras.filters import CustomFieldFilterSet
-from utilities.filters import NumericInFilter, TagFilter
+from utilities.filters import NameSlugSearchFilterSet, NumericInFilter, TagFilter
 from .models import Secret, SecretRole
 from .models import Secret, SecretRole
 
 
 
 
-class SecretRoleFilter(django_filters.FilterSet):
+class SecretRoleFilter(NameSlugSearchFilterSet):
 
 
     class Meta:
     class Meta:
         model = SecretRole
         model = SecretRole

+ 4 - 2
netbox/templates/dcim/device.html

@@ -682,7 +682,8 @@
                                     <th>Rear Port</th>
                                     <th>Rear Port</th>
                                     <th>Position</th>
                                     <th>Position</th>
                                     <th>Description</th>
                                     <th>Description</th>
-                                    <th>Connected Cable</th>
+                                    <th>Cable</th>
+                                    <th colspan="2">Connection</th>
                                     <th></th>
                                     <th></th>
                                 </tr>
                                 </tr>
                             </thead>
                             </thead>
@@ -735,7 +736,8 @@
                                     <th>Type</th>
                                     <th>Type</th>
                                     <th>Positions</th>
                                     <th>Positions</th>
                                     <th>Description</th>
                                     <th>Description</th>
-                                    <th>Connected Cable</th>
+                                    <th>Cable</th>
+                                    <th colspan="2">Connection</th>
                                     <th></th>
                                     <th></th>
                                 </tr>
                                 </tr>
                             </thead>
                             </thead>

+ 12 - 6
netbox/templates/dcim/inc/frontport.html

@@ -23,14 +23,20 @@
     {# Description #}
     {# Description #}
     <td>{{ frontport.description|placeholder }}</td>
     <td>{{ frontport.description|placeholder }}</td>
 
 
-    {# Cable #}
-    <td>
-        {% if frontport.cable %}
+    {# Cable/connection #}
+    {% if frontport.cable %}
+        <td>
             <a href="{{ frontport.cable.get_absolute_url }}">{{ frontport.cable }}</a>
             <a href="{{ frontport.cable.get_absolute_url }}">{{ frontport.cable }}</a>
-        {% else %}
+        </td>
+        {% with far_end=frontport.get_cable_peer %}
+            <td><a href="{{ far_end.parent.get_absolute_url }}">{{ far_end.parent }}</a></td>
+            <td>{{ far_end }}</td>
+        {% endwith %}
+    {% else %}
+        <td colspan="3">
             <span class="text-muted">Not connected</span>
             <span class="text-muted">Not connected</span>
-        {% endif %}
-    </td>
+        </td>
+    {% endif %}
 
 
     {# Actions #}
     {# Actions #}
     <td class="text-right">
     <td class="text-right">

+ 12 - 6
netbox/templates/dcim/inc/rearport.html

@@ -22,14 +22,20 @@
     {# Description #}
     {# Description #}
     <td>{{ rearport.description|placeholder }}</td>
     <td>{{ rearport.description|placeholder }}</td>
 
 
-    {# Cable #}
-    <td>
-        {% if rearport.cable %}
+    {# Cable/connection #}
+    {% if rearport.cable %}
+        <td>
             <a href="{{ rearport.cable.get_absolute_url }}">{{ rearport.cable }}</a>
             <a href="{{ rearport.cable.get_absolute_url }}">{{ rearport.cable }}</a>
-        {% else %}
+        </td>
+        {% with far_end=rearport.get_cable_peer %}
+            <td><a href="{{ far_end.parent.get_absolute_url }}">{{ far_end.parent }}</a></td>
+            <td>{{ far_end }}</td>
+        {% endwith %}
+    {% else %}
+        <td colspan="3">
             <span class="text-muted">Not connected</span>
             <span class="text-muted">Not connected</span>
-        {% endif %}
-    </td>
+        </td>
+    {% endif %}
 
 
     {# Actions #}
     {# Actions #}
     <td class="text-right">
     <td class="text-right">

+ 0 - 1
netbox/templates/dcim/rack_elevation_list.html

@@ -45,7 +45,6 @@
 {% endblock %}
 {% endblock %}
 
 
 {% block javascript %}
 {% block javascript %}
-    {% include 'dcim/inc/filter_rack_group.html' %}
     <script type="text/javascript">
     <script type="text/javascript">
     $(function() {
     $(function() {
         $('[data-toggle="popover"]').popover()
         $('[data-toggle="popover"]').popover()

+ 8 - 8
netbox/templates/ipam/vrf.html

@@ -83,19 +83,19 @@
                 <tr>
                 <tr>
                     <td>Description</td>
                     <td>Description</td>
                     <td>{{ vrf.description|placeholder }}</td>
                     <td>{{ vrf.description|placeholder }}</td>
+                </tr>
+                <tr>
+                    <td>Prefixes</td>
+                    <td>
+                        <a href="{% url 'ipam:prefix_list' %}?vrf={{ vrf.rd }}">{{ prefix_count }}</a>
+                    </td>
                 </tr>
                 </tr>
 		    </table>
 		    </table>
         </div>
         </div>
-        {% include 'inc/custom_fields_panel.html' with obj=vrf %}
         {% include 'extras/inc/tags_panel.html' with tags=vrf.tags.all url='ipam:vrf_list' %}
         {% include 'extras/inc/tags_panel.html' with tags=vrf.tags.all url='ipam:vrf_list' %}
 	</div>
 	</div>
 	<div class="col-md-6">
 	<div class="col-md-6">
-        <div class="panel panel-default">
-            <div class="panel-heading">
-                <strong>Prefixes</strong>
-            </div>
-            {% include 'responsive_table.html' with table=prefix_table %}
-        </div>
-	</div>
+        {% include 'inc/custom_fields_panel.html' with obj=vrf %}
+    </div>
 </div>
 </div>
 {% endblock %}
 {% endblock %}

+ 2 - 2
netbox/tenancy/filters.py

@@ -2,11 +2,11 @@ import django_filters
 from django.db.models import Q
 from django.db.models import Q
 
 
 from extras.filters import CustomFieldFilterSet
 from extras.filters import CustomFieldFilterSet
-from utilities.filters import NumericInFilter, TagFilter
+from utilities.filters import NameSlugSearchFilterSet, NumericInFilter, TagFilter
 from .models import Tenant, TenantGroup
 from .models import Tenant, TenantGroup
 
 
 
 
-class TenantGroupFilter(django_filters.FilterSet):
+class TenantGroupFilter(NameSlugSearchFilterSet):
 
 
     class Meta:
     class Meta:
         model = TenantGroup
         model = TenantGroup

+ 19 - 0
netbox/utilities/filters.py

@@ -1,4 +1,5 @@
 import django_filters
 import django_filters
+from django.db.models import Q
 from taggit.models import Tag
 from taggit.models import Tag
 
 
 
 
@@ -35,3 +36,21 @@ class TagFilter(django_filters.ModelMultipleChoiceFilter):
         kwargs.setdefault('queryset', Tag.objects.all())
         kwargs.setdefault('queryset', Tag.objects.all())
 
 
         super().__init__(*args, **kwargs)
         super().__init__(*args, **kwargs)
+
+
+class NameSlugSearchFilterSet(django_filters.FilterSet):
+    """
+    A base class for adding the search method to models which only expose the `name` and `slug` fields
+    """
+    q = django_filters.CharFilter(
+        method='search',
+        label='Search',
+    )
+
+    def search(self, queryset, name, value):
+        if not value.strip():
+            return queryset
+        return queryset.filter(
+            Q(name__icontains=value) |
+            Q(slug__icontains=value)
+        )

+ 14 - 3
netbox/virtualization/filters.py

@@ -7,19 +7,19 @@ from netaddr.core import AddrFormatError
 from dcim.models import DeviceRole, Interface, Platform, Region, Site
 from dcim.models import DeviceRole, Interface, Platform, Region, Site
 from extras.filters import CustomFieldFilterSet
 from extras.filters import CustomFieldFilterSet
 from tenancy.models import Tenant
 from tenancy.models import Tenant
-from utilities.filters import NumericInFilter, TagFilter
+from utilities.filters import NameSlugSearchFilterSet, NumericInFilter, TagFilter
 from .constants import VM_STATUS_CHOICES
 from .constants import VM_STATUS_CHOICES
 from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
 from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
 
 
 
 
-class ClusterTypeFilter(django_filters.FilterSet):
+class ClusterTypeFilter(NameSlugSearchFilterSet):
 
 
     class Meta:
     class Meta:
         model = ClusterType
         model = ClusterType
         fields = ['name', 'slug']
         fields = ['name', 'slug']
 
 
 
 
-class ClusterGroupFilter(django_filters.FilterSet):
+class ClusterGroupFilter(NameSlugSearchFilterSet):
 
 
     class Meta:
     class Meta:
         model = ClusterGroup
         model = ClusterGroup
@@ -196,6 +196,10 @@ class VirtualMachineFilter(CustomFieldFilterSet):
 
 
 
 
 class InterfaceFilter(django_filters.FilterSet):
 class InterfaceFilter(django_filters.FilterSet):
+    q = django_filters.CharFilter(
+        method='search',
+        label='Search',
+    )
     virtual_machine_id = django_filters.ModelMultipleChoiceFilter(
     virtual_machine_id = django_filters.ModelMultipleChoiceFilter(
         field_name='virtual_machine',
         field_name='virtual_machine',
         queryset=VirtualMachine.objects.all(),
         queryset=VirtualMachine.objects.all(),
@@ -225,3 +229,10 @@ class InterfaceFilter(django_filters.FilterSet):
             return queryset.filter(mac_address=mac)
             return queryset.filter(mac_address=mac)
         except AddrFormatError:
         except AddrFormatError:
             return queryset.none()
             return queryset.none()
+
+    def search(self, queryset, name, value):
+        if not value.strip():
+            return queryset
+        return queryset.filter(
+            Q(name__icontains=value)
+        )