Merge pull request #2838 from digitalocean/develop

Release v2.5.5
This commit is contained in:
Jeremy Stretch 2019-01-31 16:10:32 -05:00 committed by GitHub
commit d5fc37282f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 182 additions and 94 deletions

View File

@ -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

View File

@ -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))

View File

@ -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.

View File

@ -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.

View File

@ -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

View File

@ -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:

View File

@ -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(

View File

@ -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):

View File

@ -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

View File

@ -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)',

View File

@ -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),
),
]

View File

@ -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(

View File

@ -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):

View File

@ -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,
})

View File

@ -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__)))

View File

@ -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,

View File

@ -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

View File

@ -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>

View File

@ -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">

View File

@ -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">

View File

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

View File

@ -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 %}

View File

@ -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

View File

@ -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)
)

View File

@ -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)
)