Browse Source

Merge pull request #2041 from digitalocean/develop

Release v2.3.3
Jeremy Stretch 7 years ago
parent
commit
328958876a

+ 6 - 5
netbox/dcim/forms.py

@@ -112,9 +112,8 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldForm):
     class Meta:
         model = Site
         fields = [
-            'name', 'slug', 'status', 'region', 'tenant_group', 'tenant', 'facility', 'asn', 'description',
-            'physical_address', 'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'time_zone',
-            'comments',
+            'name', 'slug', 'status', 'region', 'tenant_group', 'tenant', 'facility', 'asn', 'time_zone', 'description',
+            'physical_address', 'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'comments',
         ]
         widgets = {
             'physical_address': SmallTextarea(attrs={'rows': 3}),
@@ -124,6 +123,8 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldForm):
             'name': "Full name of the site",
             'facility': "Data center provider and facility (e.g. Equinix NY7)",
             '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)",
             'shipping_address': "If different from the physical address"
         }
@@ -131,7 +132,7 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldForm):
 
 class SiteCSVForm(forms.ModelForm):
     status = CSVChoiceField(
-        choices=DEVICE_STATUS_CHOICES,
+        choices=SITE_STATUS_CHOICES,
         required=False,
         help_text='Operational status'
     )
@@ -705,7 +706,7 @@ class PlatformCSVForm(forms.ModelForm):
     slug = SlugField()
     manufacturer = forms.ModelChoiceField(
         queryset=Manufacturer.objects.all(),
-        required=True,
+        required=False,
         to_field_name='name',
         help_text='Manufacturer name',
         error_messages={

+ 7 - 7
netbox/dcim/querysets.py

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

+ 11 - 9
netbox/dcim/views.py

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

+ 10 - 3
netbox/extras/filters.py

@@ -43,11 +43,18 @@ class CustomFieldFilter(django_filters.Filter):
                 return queryset.none()
 
         # 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:
-            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:
-            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):

+ 3 - 5
netbox/ipam/forms.py

@@ -508,7 +508,7 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm)
 
         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']:
             parent = self.cleaned_data['interface'].parent
             if ipaddress.address.version == 4:
@@ -516,14 +516,12 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm)
             else:
                 parent.primary_ip6 = ipaddress
             parent.save()
-
-        # Clear assignment as primary for device if set.
         elif self.cleaned_data['interface']:
             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.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.save()
 

+ 1 - 1
netbox/ipam/tables.py

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

+ 2 - 2
netbox/ipam/views.py

@@ -729,8 +729,8 @@ class IPAddressAssignView(PermissionRequiredMixin, View):
                 'vrf', 'tenant', 'interface__device', 'interface__virtual_machine'
             ).filter(
                 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)
 
         return render(request, 'ipam/ipaddress_assign.html', {

+ 1 - 1
netbox/netbox/settings.py

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

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

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

+ 1 - 1
netbox/templates/ipam/ipaddress_assign.html

@@ -39,7 +39,7 @@
     </form>
     {% if table %}
         <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>
                 {% include 'utilities/obj_table.html' with table_template='panel_table.html' %}
             </div>

+ 2 - 1
netbox/utilities/forms.py

@@ -205,7 +205,8 @@ class ArrayFieldSelectMultiple(SelectWithDisabled, forms.SelectMultiple):
 
     def optgroups(self, name, value, attrs=None):
         # 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)
 
     def value_from_datadict(self, data, files, name):

+ 1 - 1
netbox/utilities/utils.py

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

+ 18 - 4
netbox/virtualization/api/serializers.py

@@ -3,10 +3,10 @@ from __future__ import unicode_literals
 from rest_framework import serializers
 
 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 extras.api.customfields import CustomFieldModelSerializer
-from ipam.models import IPAddress
+from ipam.models import IPAddress, VLAN
 from tenancy.api.serializers import NestedTenantSerializer
 from utilities.api import ChoiceFieldSerializer, ValidatedModelSerializer
 from virtualization.constants import VM_STATUS_CHOICES
@@ -133,13 +133,26 @@ class WritableVirtualMachineSerializer(CustomFieldModelSerializer):
 # 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):
     virtual_machine = NestedVirtualMachineSerializer()
+    mode = ChoiceFieldSerializer(choices=IFACE_MODE_CHOICES)
+    untagged_vlan = InterfaceVLANSerializer()
+    tagged_vlans = InterfaceVLANSerializer(many=True)
 
     class Meta:
         model = Interface
         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:
         model = Interface
         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',
         ]