Merge pull request #2041 from digitalocean/develop

Release v2.3.3
This commit is contained in:
Jeremy Stretch 2018-04-19 11:15:48 -04:00 committed by GitHub
commit 328958876a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 64 additions and 40 deletions

View File

@ -112,9 +112,8 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldForm):
class Meta: class Meta:
model = Site model = Site
fields = [ fields = [
'name', 'slug', 'status', 'region', 'tenant_group', 'tenant', 'facility', 'asn', 'description', 'name', 'slug', 'status', 'region', 'tenant_group', 'tenant', 'facility', 'asn', 'time_zone', 'description',
'physical_address', 'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'time_zone', 'physical_address', 'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'comments',
'comments',
] ]
widgets = { widgets = {
'physical_address': SmallTextarea(attrs={'rows': 3}), 'physical_address': SmallTextarea(attrs={'rows': 3}),
@ -124,6 +123,8 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldForm):
'name': "Full name of the site", 'name': "Full name of the site",
'facility': "Data center provider and facility (e.g. Equinix NY7)", 'facility': "Data center provider and facility (e.g. Equinix NY7)",
'asn': "BGP autonomous system number", 'asn': "BGP autonomous system number",
'time_zone': "Local time zone",
'description': "Short description (will appear in sites list)",
'physical_address': "Physical location of the building (e.g. for GPS)", 'physical_address': "Physical location of the building (e.g. for GPS)",
'shipping_address': "If different from the physical address" 'shipping_address': "If different from the physical address"
} }
@ -131,7 +132,7 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldForm):
class SiteCSVForm(forms.ModelForm): class SiteCSVForm(forms.ModelForm):
status = CSVChoiceField( status = CSVChoiceField(
choices=DEVICE_STATUS_CHOICES, choices=SITE_STATUS_CHOICES,
required=False, required=False,
help_text='Operational status' help_text='Operational status'
) )
@ -705,7 +706,7 @@ class PlatformCSVForm(forms.ModelForm):
slug = SlugField() slug = SlugField()
manufacturer = forms.ModelChoiceField( manufacturer = forms.ModelChoiceField(
queryset=Manufacturer.objects.all(), queryset=Manufacturer.objects.all(),
required=True, required=False,
to_field_name='name', to_field_name='name',
help_text='Manufacturer name', help_text='Manufacturer name',
error_messages={ error_messages={

View File

@ -43,13 +43,13 @@ class InterfaceQuerySet(QuerySet):
}[method] }[method]
TYPE_RE = r"SUBSTRING({} FROM '^([^0-9]+)')" TYPE_RE = r"SUBSTRING({} FROM '^([^0-9]+)')"
ID_RE = r"CAST(SUBSTRING({} FROM '^(?:[^0-9]+)([0-9]+)$') AS integer)" ID_RE = r"CAST(SUBSTRING({} FROM '^(?:[^0-9]+)(\d{{1,9}})$') AS integer)"
SLOT_RE = r"CAST(SUBSTRING({} FROM '^(?:[^0-9]+)?([0-9]+)\/') AS integer)" SLOT_RE = r"CAST(SUBSTRING({} FROM '^(?:[^0-9]+)?(\d{{1,9}})\/') AS integer)"
SUBSLOT_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9]+)?(?:[0-9]+\/)([0-9]+)') AS integer), 0)" SUBSLOT_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9]+)?(?:\d{{1,9}}\/)(\d{{1,9}})') AS integer), 0)"
POSITION_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9]+)?(?:[0-9]+\/){{2}}([0-9]+)') AS integer), 0)" POSITION_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9]+)?(?:\d{{1,9}}\/){{2}}(\d{{1,9}})') AS integer), 0)"
SUBPOSITION_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9]+)?(?:[0-9]+\/){{3}}([0-9]+)') AS integer), 0)" SUBPOSITION_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9]+)?(?:\d{{1,9}}\/){{3}}(\d{{1,9}})') AS integer), 0)"
CHANNEL_RE = r"COALESCE(CAST(SUBSTRING({} FROM ':([0-9]+)(\.[0-9]+)?$') AS integer), 0)" CHANNEL_RE = r"COALESCE(CAST(SUBSTRING({} FROM ':(\d{{1,9}})(\.\d{{1,9}})?$') AS integer), 0)"
VC_RE = r"COALESCE(CAST(SUBSTRING({} FROM '\.([0-9]+)$') AS integer), 0)" VC_RE = r"COALESCE(CAST(SUBSTRING({} FROM '\.(\d{{1,9}})$') AS integer), 0)"
fields = { fields = {
'_type': RawSQL(TYPE_RE.format(sql_col), []), '_type': RawSQL(TYPE_RE.format(sql_col), []),

View File

@ -41,19 +41,21 @@ class BulkRenameView(View):
""" """
An extendable view for renaming device components in bulk. An extendable view for renaming device components in bulk.
""" """
model = None queryset = None
form = None form = None
template_name = 'dcim/bulk_rename.html' template_name = 'dcim/bulk_rename.html'
def post(self, request): def post(self, request):
model = self.queryset.model
return_url = request.GET.get('return_url') return_url = request.GET.get('return_url')
if not return_url or not is_safe_url(url=return_url, host=request.get_host()): if not return_url or not is_safe_url(url=return_url, host=request.get_host()):
return_url = 'home' return_url = 'home'
if '_preview' in request.POST or '_apply' in request.POST: if '_preview' in request.POST or '_apply' in request.POST:
form = self.form(request.POST, initial={'pk': request.POST.getlist('pk')}) form = self.form(request.POST, initial={'pk': request.POST.getlist('pk')})
selected_objects = self.model.objects.filter(pk__in=form.initial['pk']) selected_objects = self.queryset.filter(pk__in=form.initial['pk'])
if form.is_valid(): if form.is_valid():
for obj in selected_objects: for obj in selected_objects:
@ -65,17 +67,17 @@ class BulkRenameView(View):
obj.save() obj.save()
messages.success(request, "Renamed {} {}".format( messages.success(request, "Renamed {} {}".format(
len(selected_objects), len(selected_objects),
self.model._meta.verbose_name_plural model._meta.verbose_name_plural
)) ))
return redirect(return_url) return redirect(return_url)
else: else:
form = self.form(initial={'pk': request.POST.getlist('pk')}) form = self.form(initial={'pk': request.POST.getlist('pk')})
selected_objects = self.model.objects.filter(pk__in=form.initial['pk']) selected_objects = self.queryset.filter(pk__in=form.initial['pk'])
return render(request, self.template_name, { return render(request, self.template_name, {
'form': form, 'form': form,
'obj_type_plural': self.model._meta.verbose_name_plural, 'obj_type_plural': model._meta.verbose_name_plural,
'selected_objects': selected_objects, 'selected_objects': selected_objects,
'return_url': return_url, 'return_url': return_url,
}) })
@ -1316,7 +1318,7 @@ class ConsoleServerPortDeleteView(PermissionRequiredMixin, ObjectDeleteView):
class ConsoleServerPortBulkRenameView(PermissionRequiredMixin, BulkRenameView): class ConsoleServerPortBulkRenameView(PermissionRequiredMixin, BulkRenameView):
permission_required = 'dcim.change_consoleserverport' permission_required = 'dcim.change_consoleserverport'
model = ConsoleServerPort queryset = ConsoleServerPort.objects.all()
form = forms.ConsoleServerPortBulkRenameForm form = forms.ConsoleServerPortBulkRenameForm
@ -1600,7 +1602,7 @@ class PowerOutletDeleteView(PermissionRequiredMixin, ObjectDeleteView):
class PowerOutletBulkRenameView(PermissionRequiredMixin, BulkRenameView): class PowerOutletBulkRenameView(PermissionRequiredMixin, BulkRenameView):
permission_required = 'dcim.change_poweroutlet' permission_required = 'dcim.change_poweroutlet'
model = PowerOutlet queryset = PowerOutlet.objects.all()
form = forms.PowerOutletBulkRenameForm form = forms.PowerOutletBulkRenameForm
@ -1676,7 +1678,7 @@ class InterfaceBulkEditView(PermissionRequiredMixin, BulkEditView):
class InterfaceBulkRenameView(PermissionRequiredMixin, BulkRenameView): class InterfaceBulkRenameView(PermissionRequiredMixin, BulkRenameView):
permission_required = 'dcim.change_interface' permission_required = 'dcim.change_interface'
model = Interface queryset = Interface.objects.order_naturally()
form = forms.InterfaceBulkRenameForm form = forms.InterfaceBulkRenameForm
@ -1783,7 +1785,7 @@ class DeviceBayDepopulateView(PermissionRequiredMixin, View):
class DeviceBayBulkRenameView(PermissionRequiredMixin, BulkRenameView): class DeviceBayBulkRenameView(PermissionRequiredMixin, BulkRenameView):
permission_required = 'dcim.change_devicebay' permission_required = 'dcim.change_devicebay'
model = DeviceBay queryset = DeviceBay.objects.all()
form = forms.DeviceBayBulkRenameForm form = forms.DeviceBayBulkRenameForm

View File

@ -43,11 +43,18 @@ class CustomFieldFilter(django_filters.Filter):
return queryset.none() return queryset.none()
# Apply the assigned filter logic (exact or loose) # Apply the assigned filter logic (exact or loose)
queryset = queryset.filter(custom_field_values__field__name=self.name)
if self.cf_type == CF_TYPE_BOOLEAN or self.filter_logic == CF_FILTER_EXACT: if self.cf_type == CF_TYPE_BOOLEAN or self.filter_logic == CF_FILTER_EXACT:
return queryset.filter(custom_field_values__serialized_value=value) queryset = queryset.filter(
custom_field_values__field__name=self.name,
custom_field_values__serialized_value=value
)
else: else:
return queryset.filter(custom_field_values__serialized_value__icontains=value) queryset = queryset.filter(
custom_field_values__field__name=self.name,
custom_field_values__serialized_value__icontains=value
)
return queryset
class CustomFieldFilterSet(django_filters.FilterSet): class CustomFieldFilterSet(django_filters.FilterSet):

View File

@ -508,7 +508,7 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm)
ipaddress = super(IPAddressForm, self).save(*args, **kwargs) ipaddress = super(IPAddressForm, self).save(*args, **kwargs)
# Assign this IPAddress as the primary for the associated Device. # Assign/clear this IPAddress as the primary for the associated Device/VirtualMachine.
if self.cleaned_data['primary_for_parent']: if self.cleaned_data['primary_for_parent']:
parent = self.cleaned_data['interface'].parent parent = self.cleaned_data['interface'].parent
if ipaddress.address.version == 4: if ipaddress.address.version == 4:
@ -516,14 +516,12 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm)
else: else:
parent.primary_ip6 = ipaddress parent.primary_ip6 = ipaddress
parent.save() parent.save()
# Clear assignment as primary for device if set.
elif self.cleaned_data['interface']: elif self.cleaned_data['interface']:
parent = self.cleaned_data['interface'].parent parent = self.cleaned_data['interface'].parent
if ipaddress.address.version == 4 and parent.primary_ip4 == self: if ipaddress.address.version == 4 and parent.primary_ip4 == ipaddress:
parent.primary_ip4 = None parent.primary_ip4 = None
parent.save() parent.save()
elif ipaddress.address.version == 6 and parent.primary_ip6 == self: elif ipaddress.address.version == 6 and parent.primary_ip6 == ipaddress:
parent.primary_ip6 = None parent.primary_ip6 = None
parent.save() parent.save()

View File

@ -329,7 +329,7 @@ class IPAddressAssignTable(BaseTable):
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = IPAddress model = IPAddress
fields = ('address', 'vrf', 'status', 'role', 'tenant', 'parent', 'interface') fields = ('address', 'vrf', 'status', 'role', 'tenant', 'parent', 'interface', 'description')
orderable = False orderable = False

View File

@ -729,8 +729,8 @@ class IPAddressAssignView(PermissionRequiredMixin, View):
'vrf', 'tenant', 'interface__device', 'interface__virtual_machine' 'vrf', 'tenant', 'interface__device', 'interface__virtual_machine'
).filter( ).filter(
vrf=form.cleaned_data['vrf'], vrf=form.cleaned_data['vrf'],
address__net_host=form.cleaned_data['address'], address__istartswith=form.cleaned_data['address'],
) )[:100] # Limit to 100 results
table = tables.IPAddressAssignTable(queryset) table = tables.IPAddressAssignTable(queryset)
return render(request, 'ipam/ipaddress_assign.html', { return render(request, 'ipam/ipaddress_assign.html', {

View File

@ -22,7 +22,7 @@ if sys.version_info[0] < 3:
DeprecationWarning DeprecationWarning
) )
VERSION = '2.3.2' VERSION = '2.3.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

@ -12,6 +12,7 @@
{% render_field form.facility %} {% render_field form.facility %}
{% render_field form.asn %} {% render_field form.asn %}
{% render_field form.time_zone %} {% render_field form.time_zone %}
{% render_field form.description %}
</div> </div>
</div> </div>
<div class="panel panel-default"> <div class="panel panel-default">

View File

@ -39,7 +39,7 @@
</form> </form>
{% if table %} {% if table %}
<div class="row"> <div class="row">
<div class="col-md-10 col-md-offset-1" style="margin-top: 20px"> <div class="col-md-12" style="margin-top: 20px">
<h3>Search Results</h3> <h3>Search Results</h3>
{% include 'utilities/obj_table.html' with table_template='panel_table.html' %} {% include 'utilities/obj_table.html' with table_template='panel_table.html' %}
</div> </div>

View File

@ -205,7 +205,8 @@ class ArrayFieldSelectMultiple(SelectWithDisabled, forms.SelectMultiple):
def optgroups(self, name, value, attrs=None): def optgroups(self, name, value, attrs=None):
# Split the delimited string of values into a list # Split the delimited string of values into a list
value = value[0].split(self.delimiter) if value:
value = value[0].split(self.delimiter)
return super(ArrayFieldSelectMultiple, self).optgroups(name, value, attrs) return super(ArrayFieldSelectMultiple, self).optgroups(name, value, attrs)
def value_from_datadict(self, data, files, name): def value_from_datadict(self, data, files, name):

View File

@ -14,7 +14,7 @@ def csv_format(data):
for value in data: for value in data:
# Represent None or False with empty string # Represent None or False with empty string
if value in [None, False]: if value is None or value is False:
csv.append('') csv.append('')
continue continue

View File

@ -3,10 +3,10 @@ from __future__ import unicode_literals
from rest_framework import serializers from rest_framework import serializers
from dcim.api.serializers import NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedSiteSerializer from dcim.api.serializers import NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedSiteSerializer
from dcim.constants import IFACE_FF_VIRTUAL from dcim.constants import IFACE_FF_VIRTUAL, IFACE_MODE_CHOICES
from dcim.models import Interface from dcim.models import Interface
from extras.api.customfields import CustomFieldModelSerializer from extras.api.customfields import CustomFieldModelSerializer
from ipam.models import IPAddress from ipam.models import IPAddress, VLAN
from tenancy.api.serializers import NestedTenantSerializer from tenancy.api.serializers import NestedTenantSerializer
from utilities.api import ChoiceFieldSerializer, ValidatedModelSerializer from utilities.api import ChoiceFieldSerializer, ValidatedModelSerializer
from virtualization.constants import VM_STATUS_CHOICES from virtualization.constants import VM_STATUS_CHOICES
@ -133,13 +133,26 @@ class WritableVirtualMachineSerializer(CustomFieldModelSerializer):
# VM interfaces # VM interfaces
# #
# Cannot import ipam.api.serializers.NestedVLANSerializer due to circular dependency
class InterfaceVLANSerializer(serializers.ModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlan-detail')
class Meta:
model = VLAN
fields = ['id', 'url', 'vid', 'name', 'display_name']
class InterfaceSerializer(serializers.ModelSerializer): class InterfaceSerializer(serializers.ModelSerializer):
virtual_machine = NestedVirtualMachineSerializer() virtual_machine = NestedVirtualMachineSerializer()
mode = ChoiceFieldSerializer(choices=IFACE_MODE_CHOICES)
untagged_vlan = InterfaceVLANSerializer()
tagged_vlans = InterfaceVLANSerializer(many=True)
class Meta: class Meta:
model = Interface model = Interface
fields = [ fields = [
'id', 'name', 'virtual_machine', 'enabled', 'mac_address', 'mtu', 'description', 'id', 'name', 'virtual_machine', 'enabled', 'mac_address', 'mtu', 'mode', 'untagged_vlan', 'tagged_vlans',
'description',
] ]
@ -157,5 +170,6 @@ class WritableInterfaceSerializer(ValidatedModelSerializer):
class Meta: class Meta:
model = Interface model = Interface
fields = [ fields = [
'id', 'name', 'virtual_machine', 'form_factor', 'enabled', 'mac_address', 'mtu', 'description', 'id', 'name', 'virtual_machine', 'form_factor', 'enabled', 'mac_address', 'mtu', 'mode', 'untagged_vlan',
'tagged_vlans', 'description',
] ]