Merge pull request #2346 from digitalocean/develop

Release v2.4.3
This commit is contained in:
Jeremy Stretch 2018-08-09 16:39:45 -04:00 committed by GitHub
commit f224ad2959
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 199 additions and 37 deletions

View File

@ -120,7 +120,7 @@ class NestedRackRoleSerializer(WritableNestedSerializer):
class RackSerializer(TaggitSerializer, CustomFieldModelSerializer): class RackSerializer(TaggitSerializer, CustomFieldModelSerializer):
site = NestedSiteSerializer() site = NestedSiteSerializer()
group = NestedRackGroupSerializer(required=False, allow_null=True) group = NestedRackGroupSerializer(required=False, allow_null=True, default=None)
tenant = NestedTenantSerializer(required=False, allow_null=True) tenant = NestedTenantSerializer(required=False, allow_null=True)
role = NestedRackRoleSerializer(required=False, allow_null=True) role = NestedRackRoleSerializer(required=False, allow_null=True)
type = ChoiceField(choices=RACK_TYPE_CHOICES, required=False, allow_null=True) type = ChoiceField(choices=RACK_TYPE_CHOICES, required=False, allow_null=True)
@ -666,7 +666,7 @@ class InventoryItemSerializer(TaggitSerializer, ValidatedModelSerializer):
device = NestedDeviceSerializer() device = NestedDeviceSerializer()
# Provide a default value to satisfy UniqueTogetherValidator # Provide a default value to satisfy UniqueTogetherValidator
parent = serializers.PrimaryKeyRelatedField(queryset=InventoryItem.objects.all(), allow_null=True, default=None) parent = serializers.PrimaryKeyRelatedField(queryset=InventoryItem.objects.all(), allow_null=True, default=None)
manufacturer = NestedManufacturerSerializer() manufacturer = NestedManufacturerSerializer(required=False, allow_null=True, default=None)
tags = TagListSerializerField(required=False) tags = TagListSerializerField(required=False)
class Meta: class Meta:

View File

@ -1795,7 +1795,7 @@ class InterfaceAssignVLANsForm(BootstrapMixin, forms.ModelForm):
# Compile VLAN choices # Compile VLAN choices
vlan_choices = [] vlan_choices = []
# Add global VLANs # Add non-grouped global VLANs
global_vlans = VLAN.objects.filter(site=None, group=None).exclude(pk__in=assigned_vlans) global_vlans = VLAN.objects.filter(site=None, group=None).exclude(pk__in=assigned_vlans)
vlan_choices.append(( vlan_choices.append((
'Global', [(vlan.pk, vlan) for vlan in global_vlans]) 'Global', [(vlan.pk, vlan) for vlan in global_vlans])
@ -1808,16 +1808,15 @@ class InterfaceAssignVLANsForm(BootstrapMixin, forms.ModelForm):
(group.name, [(vlan.pk, vlan) for vlan in global_group_vlans]) (group.name, [(vlan.pk, vlan) for vlan in global_group_vlans])
) )
parent = self.instance.parent site = getattr(self.instance.parent, 'site', None)
if parent is not None: if site is not None:
# Add site VLANs # Add non-grouped site VLANs
if parent.site: site_vlans = VLAN.objects.filter(site=site, group=None).exclude(pk__in=assigned_vlans)
site_vlans = VLAN.objects.filter(site=parent.site, group=None).exclude(pk__in=assigned_vlans) vlan_choices.append((site.name, [(vlan.pk, vlan) for vlan in site_vlans]))
vlan_choices.append((parent.site.name, [(vlan.pk, vlan) for vlan in site_vlans]))
# Add grouped site VLANs # Add grouped site VLANs
for group in VLANGroup.objects.filter(site=parent.site): for group in VLANGroup.objects.filter(site=site):
site_group_vlans = VLAN.objects.filter(group=group).exclude(pk__in=assigned_vlans) site_group_vlans = VLAN.objects.filter(group=group).exclude(pk__in=assigned_vlans)
vlan_choices.append(( vlan_choices.append((
'{} / {}'.format(group.site.name, group.name), '{} / {}'.format(group.site.name, group.name),

View File

@ -138,8 +138,11 @@ class ImageAttachmentViewSet(ModelViewSet):
# #
class ConfigContextViewSet(ModelViewSet): class ConfigContextViewSet(ModelViewSet):
queryset = ConfigContext.objects.prefetch_related('regions', 'sites', 'roles', 'platforms', 'tenants') queryset = ConfigContext.objects.prefetch_related(
'regions', 'sites', 'roles', 'platforms', 'tenant_groups', 'tenants',
)
serializer_class = serializers.ConfigContextSerializer serializer_class = serializers.ConfigContextSerializer
filter_class = filters.ConfigContextFilter
# #

View File

@ -6,9 +6,10 @@ from django.contrib.contenttypes.models import ContentType
from django.db.models import Q from django.db.models import Q
from taggit.models import Tag from taggit.models import Tag
from dcim.models import Site from dcim.models import DeviceRole, Platform, Region, Site
from tenancy.models import Tenant, TenantGroup
from .constants import CF_FILTER_DISABLED, CF_FILTER_EXACT, CF_TYPE_BOOLEAN, CF_TYPE_SELECT from .constants import CF_FILTER_DISABLED, CF_FILTER_EXACT, CF_TYPE_BOOLEAN, CF_TYPE_SELECT
from .models import CustomField, Graph, ExportTemplate, ObjectChange, TopologyMap, UserAction from .models import ConfigContext, CustomField, Graph, ExportTemplate, ObjectChange, TopologyMap, UserAction
class CustomFieldFilter(django_filters.Filter): class CustomFieldFilter(django_filters.Filter):
@ -124,6 +125,92 @@ class TopologyMapFilter(django_filters.FilterSet):
fields = ['name', 'slug'] fields = ['name', 'slug']
class ConfigContextFilter(django_filters.FilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
)
region_id = django_filters.ModelMultipleChoiceFilter(
name='regions',
queryset=Region.objects.all(),
label='Region',
)
region = django_filters.ModelMultipleChoiceFilter(
name='regions__slug',
queryset=Region.objects.all(),
to_field_name='slug',
label='Region (slug)',
)
site_id = django_filters.ModelMultipleChoiceFilter(
name='sites',
queryset=Site.objects.all(),
label='Site',
)
site = django_filters.ModelMultipleChoiceFilter(
name='sites__slug',
queryset=Site.objects.all(),
to_field_name='slug',
label='Site (slug)',
)
role_id = django_filters.ModelMultipleChoiceFilter(
name='roles',
queryset=DeviceRole.objects.all(),
label='Role',
)
role = django_filters.ModelMultipleChoiceFilter(
name='roles__slug',
queryset=DeviceRole.objects.all(),
to_field_name='slug',
label='Role (slug)',
)
platform_id = django_filters.ModelMultipleChoiceFilter(
name='platforms',
queryset=Platform.objects.all(),
label='Platform',
)
platform = django_filters.ModelMultipleChoiceFilter(
name='platforms__slug',
queryset=Platform.objects.all(),
to_field_name='slug',
label='Platform (slug)',
)
tenant_group_id = django_filters.ModelMultipleChoiceFilter(
name='tenant_groups',
queryset=TenantGroup.objects.all(),
label='Tenant group',
)
tenant_group = django_filters.ModelMultipleChoiceFilter(
name='tenant_groups__slug',
queryset=TenantGroup.objects.all(),
to_field_name='slug',
label='Tenant group (slug)',
)
tenant_id = django_filters.ModelMultipleChoiceFilter(
name='tenants',
queryset=Tenant.objects.all(),
label='Tenant',
)
tenant = django_filters.ModelMultipleChoiceFilter(
name='tenants__slug',
queryset=Tenant.objects.all(),
to_field_name='slug',
label='Tenant (slug)',
)
class Meta:
model = ConfigContext
fields = ['name', 'is_active']
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(name__icontains=value) |
Q(description__icontains=value) |
Q(data__icontains=value)
)
class ObjectChangeFilter(django_filters.FilterSet): class ObjectChangeFilter(django_filters.FilterSet):
q = django_filters.CharFilter( q = django_filters.CharFilter(
method='search', method='search',

View File

@ -10,8 +10,12 @@ from mptt.forms import TreeNodeMultipleChoiceField
from taggit.forms import TagField from taggit.forms import TagField
from taggit.models import Tag from taggit.models import Tag
from dcim.models import Region from dcim.models import DeviceRole, Platform, Region, Site
from utilities.forms import add_blank_choice, BootstrapMixin, BulkEditForm, LaxURLField, JSONField, SlugField from tenancy.models import Tenant, TenantGroup
from utilities.forms import (
add_blank_choice, BootstrapMixin, BulkEditForm, FilterChoiceField, FilterTreeNodeMultipleChoiceField, LaxURLField,
JSONField, SlugField,
)
from .constants import ( from .constants import (
CF_FILTER_DISABLED, CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL, CF_FILTER_DISABLED, CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL,
OBJECTCHANGE_ACTION_CHOICES, OBJECTCHANGE_ACTION_CHOICES,
@ -223,6 +227,37 @@ class ConfigContextForm(BootstrapMixin, forms.ModelForm):
] ]
class ConfigContextFilterForm(BootstrapMixin, forms.Form):
q = forms.CharField(
required=False,
label='Search'
)
region = FilterTreeNodeMultipleChoiceField(
queryset=Region.objects.all(),
to_field_name='slug'
)
site = FilterChoiceField(
queryset=Site.objects.all(),
to_field_name='slug'
)
role = FilterChoiceField(
queryset=DeviceRole.objects.all(),
to_field_name='slug'
)
platform = FilterChoiceField(
queryset=Platform.objects.all(),
to_field_name='slug'
)
tenant_group = FilterChoiceField(
queryset=TenantGroup.objects.all(),
to_field_name='slug'
)
tenant = FilterChoiceField(
queryset=Tenant.objects.all(),
to_field_name='slug'
)
# #
# Image attachments # Image attachments
# #

View File

@ -72,15 +72,10 @@ class ConfigContextTable(BaseTable):
is_active = BooleanColumn( is_active = BooleanColumn(
verbose_name='Active' verbose_name='Active'
) )
actions = tables.TemplateColumn(
template_code=CONFIGCONTEXT_ACTIONS,
attrs={'td': {'class': 'text-right'}},
verbose_name=''
)
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = ConfigContext model = ConfigContext
fields = ('pk', 'name', 'weight', 'is_active', 'description', 'actions') fields = ('pk', 'name', 'weight', 'is_active', 'description')
class ObjectChangeTable(BaseTable): class ObjectChangeTable(BaseTable):

View File

@ -14,7 +14,7 @@ from taggit.models import Tag
from utilities.forms import ConfirmationForm from utilities.forms import ConfirmationForm
from utilities.views import BulkDeleteView, ObjectDeleteView, ObjectEditView, ObjectListView from utilities.views import BulkDeleteView, ObjectDeleteView, ObjectEditView, ObjectListView
from . import filters from . import filters
from .forms import ConfigContextForm, ImageAttachmentForm, ObjectChangeFilterForm, TagForm from .forms import ConfigContextForm, ConfigContextFilterForm, ImageAttachmentForm, ObjectChangeFilterForm, TagForm
from .models import ConfigContext, ImageAttachment, ObjectChange, ReportResult from .models import ConfigContext, ImageAttachment, ObjectChange, ReportResult
from .reports import get_report, get_reports from .reports import get_report, get_reports
from .tables import ConfigContextTable, ObjectChangeTable, TagTable from .tables import ConfigContextTable, ObjectChangeTable, TagTable
@ -56,6 +56,8 @@ class TagBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
class ConfigContextListView(ObjectListView): class ConfigContextListView(ObjectListView):
queryset = ConfigContext.objects.all() queryset = ConfigContext.objects.all()
filter = filters.ConfigContextFilter
filter_form = ConfigContextFilterForm
table = ConfigContextTable table = ConfigContextTable
template_name = 'extras/configcontext_list.html' template_name = 'extras/configcontext_list.html'

View File

@ -140,10 +140,11 @@ class PrefixViewSet(CustomFieldModelViewSet):
available_prefixes.remove(allocated_prefix) available_prefixes.remove(allocated_prefix)
# Initialize the serializer with a list or a single object depending on what was requested # Initialize the serializer with a list or a single object depending on what was requested
context = {'request': request}
if isinstance(request.data, list): if isinstance(request.data, list):
serializer = serializers.PrefixSerializer(data=requested_prefixes, many=True) serializer = serializers.PrefixSerializer(data=requested_prefixes, many=True, context=context)
else: else:
serializer = serializers.PrefixSerializer(data=requested_prefixes[0]) serializer = serializers.PrefixSerializer(data=requested_prefixes[0], context=context)
# Create the new Prefix(es) # Create the new Prefix(es)
if serializer.is_valid(): if serializer.is_valid():
@ -199,10 +200,11 @@ class PrefixViewSet(CustomFieldModelViewSet):
requested_ip['vrf'] = prefix.vrf.pk if prefix.vrf else None requested_ip['vrf'] = prefix.vrf.pk if prefix.vrf else None
# Initialize the serializer with a list or a single object depending on what was requested # Initialize the serializer with a list or a single object depending on what was requested
context = {'request': request}
if isinstance(request.data, list): if isinstance(request.data, list):
serializer = serializers.IPAddressSerializer(data=requested_ips, many=True) serializer = serializers.IPAddressSerializer(data=requested_ips, many=True, context=context)
else: else:
serializer = serializers.IPAddressSerializer(data=requested_ips[0]) serializer = serializers.IPAddressSerializer(data=requested_ips[0], context=context)
# Create the new IP address(es) # Create the new IP address(es)
if serializer.is_valid(): if serializer.is_valid():

View File

@ -494,7 +494,8 @@ class PrefixTest(APITestCase):
def test_create_single_available_prefix(self): def test_create_single_available_prefix(self):
prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/28'), is_pool=True) vrf = VRF.objects.create(name='Test VRF 1', rd='1234')
prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/28'), vrf=vrf, is_pool=True)
url = reverse('ipam-api:prefix-available-prefixes', kwargs={'pk': prefix.pk}) url = reverse('ipam-api:prefix-available-prefixes', kwargs={'pk': prefix.pk})
# Create four available prefixes with individual requests # Create four available prefixes with individual requests
@ -512,6 +513,7 @@ class PrefixTest(APITestCase):
response = self.client.post(url, data, format='json', **self.header) response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(response.data['prefix'], prefixes_to_be_created[i]) self.assertEqual(response.data['prefix'], prefixes_to_be_created[i])
self.assertEqual(response.data['vrf']['id'], vrf.pk)
self.assertEqual(response.data['description'], data['description']) self.assertEqual(response.data['description'], data['description'])
# Try to create one more prefix # Try to create one more prefix
@ -562,7 +564,8 @@ class PrefixTest(APITestCase):
def test_create_single_available_ip(self): def test_create_single_available_ip(self):
prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/30'), is_pool=True) vrf = VRF.objects.create(name='Test VRF 1', rd='1234')
prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/30'), vrf=vrf, is_pool=True)
url = reverse('ipam-api:prefix-available-ips', kwargs={'pk': prefix.pk}) url = reverse('ipam-api:prefix-available-ips', kwargs={'pk': prefix.pk})
# Create all four available IPs with individual requests # Create all four available IPs with individual requests
@ -572,6 +575,7 @@ class PrefixTest(APITestCase):
} }
response = self.client.post(url, data, format='json', **self.header) response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(response.data['vrf']['id'], vrf.pk)
self.assertEqual(response.data['description'], data['description']) self.assertEqual(response.data['description'], data['description'])
# Try to create one more IP # Try to create one more IP

View File

@ -22,7 +22,7 @@ if sys.version_info[0] < 3:
DeprecationWarning DeprecationWarning
) )
VERSION = '2.4.2' VERSION = '2.4.3'
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))

View File

@ -573,7 +573,7 @@
{% endif %} {% endif %}
{% if cs_ports or device.device_type.is_console_server %} {% if cs_ports or device.device_type.is_console_server %}
{% if perms.dcim.delete_consoleserverport %} {% if perms.dcim.delete_consoleserverport %}
<form method="post" action="{% url 'dcim:consoleserverport_bulk_delete' pk=device.pk %}"> <form method="post">
{% csrf_token %} {% csrf_token %}
{% endif %} {% endif %}
<div class="panel panel-default"> <div class="panel panel-default">
@ -606,12 +606,12 @@
<button type="submit" name="_rename" formaction="{% url 'dcim:consoleserverport_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs"> <button type="submit" name="_rename" formaction="{% url 'dcim:consoleserverport_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
</button> </button>
<button type="submit" name="_disconnect" formaction="{% url 'dcim:consoleserverport_bulk_disconnect' pk=device.pk %}" class="btn btn-danger btn-xs"> <button type="submit" name="_disconnect" formaction="{% url 'dcim:consoleserverport_bulk_disconnect' pk=device.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect <span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect
</button> </button>
{% endif %} {% endif %}
{% if cs_ports and perms.dcim.delete_consoleserverport %} {% if cs_ports and perms.dcim.delete_consoleserverport %}
<button type="submit" class="btn btn-danger btn-xs"> <button type="submit" formaction="{% url 'dcim:consoleserverport_bulk_delete' pk=device.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
</button> </button>
{% endif %} {% endif %}
@ -631,7 +631,7 @@
{% endif %} {% endif %}
{% if power_outlets or device.device_type.is_pdu %} {% if power_outlets or device.device_type.is_pdu %}
{% if perms.dcim.delete_poweroutlet %} {% if perms.dcim.delete_poweroutlet %}
<form method="post" action="{% url 'dcim:poweroutlet_bulk_delete' pk=device.pk %}"> <form method="post">
{% csrf_token %} {% csrf_token %}
{% endif %} {% endif %}
<div class="panel panel-default"> <div class="panel panel-default">
@ -664,12 +664,12 @@
<button type="submit" name="_rename" formaction="{% url 'dcim:poweroutlet_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs"> <button type="submit" name="_rename" formaction="{% url 'dcim:poweroutlet_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
</button> </button>
<button type="submit" name="_disconnect" formaction="{% url 'dcim:poweroutlet_bulk_disconnect' pk=device.pk %}" class="btn btn-danger btn-xs"> <button type="submit" name="_disconnect" formaction="{% url 'dcim:poweroutlet_bulk_disconnect' pk=device.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect <span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect
</button> </button>
{% endif %} {% endif %}
{% if power_outlets and perms.dcim.delete_poweroutlet %} {% if power_outlets and perms.dcim.delete_poweroutlet %}
<button type="submit" class="btn btn-danger btn-xs"> <button type="submit" formaction="{% url 'dcim:poweroutlet_bulk_delete' pk=device.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
</button> </button>
{% endif %} {% endif %}

View File

@ -140,6 +140,20 @@
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
<tr>
<td>Tenant Groups</td>
<td>
{% if configcontext.tenant_groups.all %}
<ul>
{% for tenant_group in configcontext.tenant_groups.all %}
<li><a href="{{ tenant_group.get_absolute_url }}">{{ tenant_group }}</a></li>
{% endfor %}
</ul>
{% else %}
<span class="text-muted">None</span>
{% endif %}
</td>
</tr>
<tr> <tr>
<td>Tenants</td> <td>Tenants</td>
<td> <td>

View File

@ -9,8 +9,11 @@
</div> </div>
<h1>{% block title %}Config Contexts{% endblock %}</h1> <h1>{% block title %}Config Contexts{% endblock %}</h1>
<div class="row"> <div class="row">
<div class="col-md-12"> <div class="col-md-9">
{% include 'utilities/obj_table.html' with bulk_delete_url='extras:configcontext_bulk_delete' %} {% include 'utilities/obj_table.html' with bulk_delete_url='extras:configcontext_bulk_delete' %}
</div> </div>
<div class="col-md-3">
{% include 'inc/search_panel.html' %}
</div>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -170,7 +170,9 @@ class WritableNestedSerializer(ModelSerializer):
if data is None: if data is None:
return None return None
try: try:
return self.Meta.model.objects.get(pk=data) return self.Meta.model.objects.get(pk=int(data))
except (TypeError, ValueError):
raise ValidationError("Primary key must be an integer")
except ObjectDoesNotExist: except ObjectDoesNotExist:
raise ValidationError("Invalid ID") raise ValidationError("Invalid ID")

View File

@ -260,6 +260,22 @@ class VirtualMachine(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
def get_absolute_url(self): def get_absolute_url(self):
return reverse('virtualization:virtualmachine', args=[self.pk]) return reverse('virtualization:virtualmachine', args=[self.pk])
def clean(self):
# Validate primary IP addresses
interfaces = self.interfaces.all()
for field in ['primary_ip4', 'primary_ip6']:
ip = getattr(self, field)
if ip is not None:
if ip.interface in interfaces:
pass
elif self.primary_ip4.nat_inside is not None and self.primary_ip4.nat_inside.interface in interfaces:
pass
else:
raise ValidationError({
field: "The specified IP address ({}) is not assigned to this VM.".format(ip),
})
def to_csv(self): def to_csv(self):
return ( return (
self.name, self.name,