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

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)
 
 ## Enhancements

+ 1 - 1
README.md

@@ -37,7 +37,7 @@ and run `upgrade.sh`.
 
 ## 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))
 * [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 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.
 

+ 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).
 
-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.
 

+ 2 - 2
netbox/circuits/filters.py

@@ -4,7 +4,7 @@ from django.db.models import Q
 from dcim.models import Site
 from extras.filters import CustomFieldFilterSet
 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 .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:
         model = CircuitType

+ 5 - 1
netbox/circuits/models.py

@@ -3,7 +3,7 @@ from django.db import models
 from django.urls import reverse
 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.models import CableTermination
 from extras.models import CustomFieldModel, ObjectChange
@@ -283,6 +283,10 @@ class CircuitTermination(CableTermination):
             object_data=serialize_object(self)
         ).save()
 
+    @property
+    def parent(self):
+        return self.circuit
+
     def get_peer_termination(self):
         peer_side = 'Z' if self.term_side == 'A' else 'A'
         try:

+ 19 - 34
netbox/dcim/filters.py

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

+ 0 - 1
netbox/dcim/forms.py

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

+ 12 - 0
netbox/dcim/models.py

@@ -68,6 +68,10 @@ class ComponentModel(models.Model):
             object_data=serialize_object(self)
         ).save()
 
+    @property
+    def parent(self):
+        return getattr(self, 'device', None)
+
 
 class CableTermination(models.Model):
     cable = models.ForeignKey(
@@ -162,6 +166,14 @@ class CableTermination(models.Model):
 
         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

+ 8 - 4
netbox/ipam/filters.py

@@ -7,7 +7,7 @@ from netaddr.core import AddrFormatError
 from dcim.models import Site, Device, Interface
 from extras.filters import CustomFieldFilterSet
 from tenancy.models import Tenant
-from utilities.filters import NumericInFilter, TagFilter
+from utilities.filters import NameSlugSearchFilterSet, NumericInFilter, TagFilter
 from virtualization.models import VirtualMachine
 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
@@ -48,7 +48,7 @@ class VRFFilter(CustomFieldFilterSet, django_filters.FilterSet):
         fields = ['name', 'rd', 'enforce_unique']
 
 
-class RIRFilter(django_filters.FilterSet):
+class RIRFilter(NameSlugSearchFilterSet):
     id__in = NumericInFilter(
         field_name='id',
         lookup_expr='in'
@@ -96,7 +96,11 @@ class AggregateFilter(CustomFieldFilterSet, django_filters.FilterSet):
         return queryset.filter(qs_filter)
 
 
-class RoleFilter(django_filters.FilterSet):
+class RoleFilter(NameSlugSearchFilterSet):
+    q = django_filters.CharFilter(
+        method='search',
+        label='Search',
+    )
 
     class Meta:
         model = Role
@@ -373,7 +377,7 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet):
             return queryset.none()
 
 
-class VLANGroupFilter(django_filters.FilterSet):
+class VLANGroupFilter(NameSlugSearchFilterSet):
     site_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Site.objects.all(),
         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(
         max_length=21,
         unique=True,
+        blank=True,
+        null=True,
         verbose_name='Route distinguisher'
     )
     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.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):
 
@@ -44,19 +44,26 @@ class VRFTest(APITestCase):
 
     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')
-        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):
 

+ 2 - 5
netbox/ipam/views.py

@@ -126,14 +126,11 @@ class VRFView(View):
     def get(self, request, 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', {
             '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__)))
 

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

@@ -197,8 +197,8 @@ $(document).ready(function() {
                     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]
                     results.unshift({
                         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 extras.filters import CustomFieldFilterSet
-from utilities.filters import NumericInFilter, TagFilter
+from utilities.filters import NameSlugSearchFilterSet, NumericInFilter, TagFilter
 from .models import Secret, SecretRole
 
 
-class SecretRoleFilter(django_filters.FilterSet):
+class SecretRoleFilter(NameSlugSearchFilterSet):
 
     class Meta:
         model = SecretRole

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

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

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

@@ -23,14 +23,20 @@
     {# Description #}
     <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>
-        {% 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>
-        {% endif %}
-    </td>
+        </td>
+    {% endif %}
 
     {# Actions #}
     <td class="text-right">

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

@@ -22,14 +22,20 @@
     {# Description #}
     <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>
-        {% 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>
-        {% endif %}
-    </td>
+        </td>
+    {% endif %}
 
     {# Actions #}
     <td class="text-right">

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

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

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

@@ -83,19 +83,19 @@
                 <tr>
                     <td>Description</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>
 		    </table>
         </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' %}
 	</div>
 	<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>
 {% endblock %}

+ 2 - 2
netbox/tenancy/filters.py

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

+ 19 - 0
netbox/utilities/filters.py

@@ -1,4 +1,5 @@
 import django_filters
+from django.db.models import Q
 from taggit.models import Tag
 
 
@@ -35,3 +36,21 @@ class TagFilter(django_filters.ModelMultipleChoiceFilter):
         kwargs.setdefault('queryset', Tag.objects.all())
 
         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 extras.filters import CustomFieldFilterSet
 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 .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
 
 
-class ClusterTypeFilter(django_filters.FilterSet):
+class ClusterTypeFilter(NameSlugSearchFilterSet):
 
     class Meta:
         model = ClusterType
         fields = ['name', 'slug']
 
 
-class ClusterGroupFilter(django_filters.FilterSet):
+class ClusterGroupFilter(NameSlugSearchFilterSet):
 
     class Meta:
         model = ClusterGroup
@@ -196,6 +196,10 @@ class VirtualMachineFilter(CustomFieldFilterSet):
 
 
 class InterfaceFilter(django_filters.FilterSet):
+    q = django_filters.CharFilter(
+        method='search',
+        label='Search',
+    )
     virtual_machine_id = django_filters.ModelMultipleChoiceFilter(
         field_name='virtual_machine',
         queryset=VirtualMachine.objects.all(),
@@ -225,3 +229,10 @@ class InterfaceFilter(django_filters.FilterSet):
             return queryset.filter(mac_address=mac)
         except AddrFormatError:
             return queryset.none()
+
+    def search(self, queryset, name, value):
+        if not value.strip():
+            return queryset
+        return queryset.filter(
+            Q(name__icontains=value)
+        )