فهرست منبع

Merge pull request #625 from digitalocean/develop

Release v1.6.3
Jeremy Stretch 9 سال پیش
والد
کامیت
c171547037

+ 8 - 3
netbox/circuits/forms.py

@@ -3,7 +3,6 @@ from django.db.models import Count
 
 from dcim.models import Site, Device, Interface, Rack, IFACE_FF_VIRTUAL
 from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
-from tenancy.forms import bulkedit_tenant_choices
 from tenancy.models import Tenant
 from utilities.forms import (
     APISelect, BootstrapMixin, BulkImportForm, CommentField, CSVDataField, FilterChoiceField, Livesearch, SmallTextarea,
@@ -57,6 +56,9 @@ class ProviderBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
     admin_contact = forms.CharField(required=False, widget=SmallTextarea, label='Admin contact')
     comments = CommentField()
 
+    class Meta:
+        nullable_fields = ['asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments']
+
 
 class ProviderFilterForm(BootstrapMixin, CustomFieldFilterForm):
     model = Provider
@@ -86,7 +88,7 @@ class CircuitForm(BootstrapMixin, CustomFieldForm):
                                                    attrs={'filter-for': 'device'}))
     device = forms.ModelChoiceField(queryset=Device.objects.all(), required=False, label='Device',
                                     widget=APISelect(api_url='/api/dcim/devices/?rack_id={{rack}}',
-                                                     attrs={'filter-for': 'interface'}))
+                                                     display_field='display_name', attrs={'filter-for': 'interface'}))
     livesearch = forms.CharField(required=False, label='Device', widget=Livesearch(
         query_key='q', query_url='dcim-api:device_list', field_to_update='device')
     )
@@ -178,11 +180,14 @@ class CircuitBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
     pk = forms.ModelMultipleChoiceField(queryset=Circuit.objects.all(), widget=forms.MultipleHiddenInput)
     type = forms.ModelChoiceField(queryset=CircuitType.objects.all(), required=False)
     provider = forms.ModelChoiceField(queryset=Provider.objects.all(), required=False)
-    tenant = forms.TypedChoiceField(choices=bulkedit_tenant_choices, coerce=int, required=False, label='Tenant')
+    tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
     port_speed = forms.IntegerField(required=False, label='Port speed (Kbps)')
     commit_rate = forms.IntegerField(required=False, label='Commit rate (Kbps)')
     comments = CommentField()
 
+    class Meta:
+        nullable_fields = ['tenant', 'port_speed', 'commit_rate', 'comments']
+
 
 class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm):
     model = Circuit

+ 52 - 49
netbox/dcim/forms.py

@@ -1,23 +1,24 @@
 import re
 
 from django import forms
+from django.core.exceptions import ValidationError
 from django.db.models import Count, Q
 
 from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
 from ipam.models import IPAddress
-from tenancy.forms import bulkedit_tenant_choices
 from tenancy.models import Tenant
 from utilities.forms import (
-    APISelect, add_blank_choice, BootstrapMixin, BulkImportForm, CommentField, CSVDataField, ExpandableNameField,
-    FilterChoiceField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea, SlugField,
+    APISelect, add_blank_choice, BootstrapMixin, BulkEditForm, BulkImportForm, CommentField, CSVDataField,
+    ExpandableNameField, FilterChoiceField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea,
+    SlugField,
 )
 
 from .models import (
     DeviceBay, DeviceBayTemplate, CONNECTION_STATUS_CHOICES, CONNECTION_STATUS_PLANNED, CONNECTION_STATUS_CONNECTED,
     ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceRole, DeviceType,
-    Interface, IFACE_FF_VIRTUAL, InterfaceConnection, InterfaceTemplate, Manufacturer, Module, Platform, PowerOutlet,
-    PowerOutletTemplate, PowerPort, PowerPortTemplate, RACK_TYPE_CHOICES, RACK_WIDTH_CHOICES, Rack, RackGroup, RackRole,
-    Site, STATUS_CHOICES, SUBDEVICE_ROLE_CHILD
+    Interface, IFACE_FF_CHOICES, IFACE_FF_VIRTUAL, InterfaceConnection, InterfaceTemplate, Manufacturer, Module,
+    Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, RACK_TYPE_CHOICES, RACK_WIDTH_CHOICES,
+    Rack, RackGroup, RackRole, Site, STATUS_CHOICES, SUBDEVICE_ROLE_CHILD
 )
 
 
@@ -42,37 +43,12 @@ def get_device_by_name_or_pk(name):
     return device
 
 
-def bulkedit_platform_choices():
-    choices = [
-        (None, '---------'),
-        (0, 'None'),
-    ]
-    choices += [(p.pk, p.name) for p in Platform.objects.all()]
-    return choices
-
-
-def bulkedit_rackgroup_choices():
-    """
-    Include an option to remove the currently assigned group from a rack.
-    """
-    choices = [
-        (None, '---------'),
-        (0, 'None'),
-    ]
-    choices += [(r.pk, r) for r in RackGroup.objects.all()]
-    return choices
-
-
-def bulkedit_rackrole_choices():
+def validate_connection_status(value):
     """
-    Include an option to remove the currently assigned role from a rack.
+    Custom validator for connection statuses. value must be either "planned" or "connected" (case-insensitive).
     """
-    choices = [
-        (None, '---------'),
-        (0, 'None'),
-    ]
-    choices += [(r.pk, r.name) for r in RackRole.objects.all()]
-    return choices
+    if value.lower() not in ['planned', 'connected']:
+        raise ValidationError('Invalid connection status ({}); must be either "planned" or "connected".'.format(value))
 
 
 #
@@ -114,7 +90,10 @@ class SiteImportForm(BulkImportForm, BootstrapMixin):
 
 class SiteBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
     pk = forms.ModelMultipleChoiceField(queryset=Site.objects.all(), widget=forms.MultipleHiddenInput)
-    tenant = forms.TypedChoiceField(choices=bulkedit_tenant_choices, coerce=int, required=False, label='Tenant')
+    tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
+
+    class Meta:
+        nullable_fields = ['tenant']
 
 
 class SiteFilterForm(BootstrapMixin, CustomFieldFilterForm):
@@ -234,14 +213,17 @@ class RackImportForm(BulkImportForm, BootstrapMixin):
 class RackBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
     pk = forms.ModelMultipleChoiceField(queryset=Rack.objects.all(), widget=forms.MultipleHiddenInput)
     site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False, label='Site')
-    group = forms.TypedChoiceField(choices=bulkedit_rackgroup_choices, coerce=int, required=False, label='Group')
-    tenant = forms.TypedChoiceField(choices=bulkedit_tenant_choices, coerce=int, required=False, label='Tenant')
-    role = forms.TypedChoiceField(choices=bulkedit_rackrole_choices, coerce=int, required=False, label='Role')
+    group = forms.ModelChoiceField(queryset=RackGroup.objects.all(), required=False, label='Group')
+    tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
+    role = forms.ModelChoiceField(queryset=RackRole.objects.all(), required=False)
     type = forms.ChoiceField(choices=add_blank_choice(RACK_TYPE_CHOICES), required=False, label='Type')
     width = forms.ChoiceField(choices=add_blank_choice(RACK_WIDTH_CHOICES), required=False, label='Width')
     u_height = forms.IntegerField(required=False, label='Height (U)')
     comments = CommentField()
 
+    class Meta:
+        nullable_fields = ['group', 'tenant', 'role', 'comments']
+
 
 class RackFilterForm(BootstrapMixin, CustomFieldFilterForm):
     model = Rack
@@ -279,7 +261,7 @@ class DeviceTypeForm(forms.ModelForm, BootstrapMixin):
                   'is_pdu', 'is_network_device', 'subdevice_role']
 
 
-class DeviceTypeBulkEditForm(forms.Form, BootstrapMixin):
+class DeviceTypeBulkEditForm(BulkEditForm, BootstrapMixin):
     pk = forms.ModelMultipleChoiceField(queryset=DeviceType.objects.all(), widget=forms.MultipleHiddenInput)
     manufacturer = forms.ModelChoiceField(queryset=Manufacturer.objects.all(), required=False)
     u_height = forms.IntegerField(min_value=1, required=False)
@@ -334,6 +316,14 @@ class InterfaceTemplateForm(forms.ModelForm, BootstrapMixin):
         fields = ['name_pattern', 'form_factor', 'mgmt_only']
 
 
+class InterfaceTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
+    pk = forms.ModelMultipleChoiceField(queryset=InterfaceTemplate.objects.all(), widget=forms.MultipleHiddenInput)
+    form_factor = forms.ChoiceField(choices=add_blank_choice(IFACE_FF_CHOICES), required=False)
+
+    class Meta:
+        nullable_fields = []
+
+
 class DeviceBayTemplateForm(forms.ModelForm, BootstrapMixin):
     name_pattern = ExpandableNameField(label='Name')
 
@@ -583,12 +573,14 @@ class DeviceBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
     pk = forms.ModelMultipleChoiceField(queryset=Device.objects.all(), widget=forms.MultipleHiddenInput)
     device_type = forms.ModelChoiceField(queryset=DeviceType.objects.all(), required=False, label='Type')
     device_role = forms.ModelChoiceField(queryset=DeviceRole.objects.all(), required=False, label='Role')
-    tenant = forms.TypedChoiceField(choices=bulkedit_tenant_choices, coerce=int, required=False, label='Tenant')
-    platform = forms.TypedChoiceField(choices=bulkedit_platform_choices, coerce=int, required=False,
-                                      label='Platform')
+    tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
+    platform = forms.ModelChoiceField(queryset=Platform.objects.all(), required=False)
     status = forms.ChoiceField(choices=FORM_STATUS_CHOICES, required=False, initial='', label='Status')
     serial = forms.CharField(max_length=50, required=False, label='Serial Number')
 
+    class Meta:
+        nullable_fields = ['tenant', 'platform']
+
 
 class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm):
     model = Device
@@ -631,7 +623,7 @@ class ConsoleConnectionCSVForm(forms.Form):
     device = FlexibleModelChoiceField(queryset=Device.objects.all(), to_field_name='name',
                                       error_messages={'invalid_choice': 'Device not found'})
     console_port = forms.CharField()
-    status = forms.ChoiceField(choices=[('planned', 'Planned'), ('connected', 'Connected')])
+    status = forms.CharField(validators=[validate_connection_status])
 
     def clean(self):
 
@@ -695,6 +687,7 @@ class ConsolePortConnectionForm(forms.ModelForm, BootstrapMixin):
                                   widget=forms.Select(attrs={'filter-for': 'console_server'}))
     console_server = forms.ModelChoiceField(queryset=Device.objects.all(), label='Console Server', required=False,
                                             widget=APISelect(api_url='/api/dcim/devices/?rack_id={{rack}}&is_console_server=True',
+                                                             display_field='display_name',
                                                              attrs={'filter-for': 'cs_port'}))
     livesearch = forms.CharField(required=False, label='Console Server', widget=Livesearch(
         query_key='q', query_url='dcim-api:device_list', field_to_update='console_server')
@@ -762,7 +755,7 @@ class ConsoleServerPortConnectionForm(forms.Form, BootstrapMixin):
                                   widget=forms.Select(attrs={'filter-for': 'device'}))
     device = forms.ModelChoiceField(queryset=Device.objects.all(), label='Device', required=False,
                                     widget=APISelect(api_url='/api/dcim/devices/?rack_id={{rack}}',
-                                                     attrs={'filter-for': 'port'}))
+                                                     display_field='display_name', attrs={'filter-for': 'port'}))
     livesearch = forms.CharField(required=False, label='Device', widget=Livesearch(
         query_key='q', query_url='dcim-api:device_list', field_to_update='device')
     )
@@ -826,7 +819,7 @@ class PowerConnectionCSVForm(forms.Form):
     device = FlexibleModelChoiceField(queryset=Device.objects.all(), to_field_name='name',
                                       error_messages={'invalid_choice': 'Device not found'})
     power_port = forms.CharField()
-    status = forms.ChoiceField(choices=[('planned', 'Planned'), ('connected', 'Connected')])
+    status = forms.CharField(validators=[validate_connection_status])
 
     def clean(self):
 
@@ -891,7 +884,7 @@ class PowerPortConnectionForm(forms.ModelForm, BootstrapMixin):
                                   widget=forms.Select(attrs={'filter-for': 'pdu'}))
     pdu = forms.ModelChoiceField(queryset=Device.objects.all(), label='PDU', required=False,
                                  widget=APISelect(api_url='/api/dcim/devices/?rack_id={{rack}}&is_pdu=True',
-                                                  attrs={'filter-for': 'power_outlet'}))
+                                                  display_field='display_name', attrs={'filter-for': 'power_outlet'}))
     livesearch = forms.CharField(required=False, label='PDU', widget=Livesearch(
         query_key='q', query_url='dcim-api:device_list', field_to_update='pdu')
     )
@@ -958,7 +951,7 @@ class PowerOutletConnectionForm(forms.Form, BootstrapMixin):
                                   widget=forms.Select(attrs={'filter-for': 'device'}))
     device = forms.ModelChoiceField(queryset=Device.objects.all(), label='Device', required=False,
                                     widget=APISelect(api_url='/api/dcim/devices/?rack_id={{rack}}',
-                                                     attrs={'filter-for': 'port'}))
+                                                     display_field='display_name', attrs={'filter-for': 'port'}))
     livesearch = forms.CharField(required=False, label='Device', widget=Livesearch(
         query_key='q', query_url='dcim-api:device_list', field_to_update='device')
     )
@@ -1023,6 +1016,15 @@ class InterfaceBulkCreateForm(InterfaceCreateForm, BootstrapMixin):
     pk = forms.ModelMultipleChoiceField(queryset=Device.objects.all(), widget=forms.MultipleHiddenInput)
 
 
+class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
+    pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput)
+    form_factor = forms.ChoiceField(choices=add_blank_choice(IFACE_FF_CHOICES), required=False)
+    description = forms.CharField(max_length=100, required=False)
+
+    class Meta:
+        nullable_fields = ['description']
+
+
 #
 # Interface connections
 #
@@ -1033,6 +1035,7 @@ class InterfaceConnectionForm(forms.ModelForm, BootstrapMixin):
                                     widget=forms.Select(attrs={'filter-for': 'device_b'}))
     device_b = forms.ModelChoiceField(queryset=Device.objects.all(), label='Device', required=False,
                                       widget=APISelect(api_url='/api/dcim/devices/?rack_id={{rack_b}}',
+                                                       display_field='display_name',
                                                        attrs={'filter-for': 'interface_b'}))
     livesearch = forms.CharField(required=False, label='Device', widget=Livesearch(
         query_key='q', query_url='dcim-api:device_list', field_to_update='device_b')
@@ -1087,7 +1090,7 @@ class InterfaceConnectionCSVForm(forms.Form):
     device_b = FlexibleModelChoiceField(queryset=Device.objects.all(), to_field_name='name',
                                         error_messages={'invalid_choice': 'Device B not found.'})
     interface_b = forms.CharField()
-    status = forms.ChoiceField(choices=[('planned', 'Planned'), ('connected', 'Connected')])
+    status = forms.CharField(validators=[validate_connection_status])
 
     def clean(self):
 

+ 2 - 4
netbox/dcim/urls.py

@@ -3,10 +3,6 @@ from django.conf.urls import url
 from secrets.views import secret_add
 
 from . import views
-from .models import (
-    ConsolePortTemplate, ConsoleServerPortTemplate, DeviceBayTemplate, PowerPortTemplate, PowerOutletTemplate,
-    InterfaceTemplate,
-)
 
 
 urlpatterns = [
@@ -75,6 +71,7 @@ urlpatterns = [
 
     # Interface templates
     url(r'^device-types/(?P<pk>\d+)/interfaces/add/$', views.InterfaceTemplateAddView.as_view(), name='devicetype_add_interface'),
+    url(r'^device-types/(?P<pk>\d+)/interfaces/edit/$', views.InterfaceTemplateBulkEditView.as_view(), name='devicetype_bulkedit_interface'),
     url(r'^device-types/(?P<pk>\d+)/interfaces/delete/$', views.InterfaceTemplateBulkDeleteView.as_view(), name='devicetype_delete_interface'),
 
     # Device bay templates
@@ -159,6 +156,7 @@ urlpatterns = [
     # Interfaces
     url(r'^devices/interfaces/add/$', views.InterfaceBulkAddView.as_view(), name='interface_add_multi'),
     url(r'^devices/(?P<pk>\d+)/interfaces/add/$', views.interface_add, name='interface_add'),
+    url(r'^devices/(?P<pk>\d+)/interfaces/edit/$', views.InterfaceBulkEditView.as_view(), name='interface_bulk_edit'),
     url(r'^devices/(?P<pk>\d+)/interfaces/delete/$', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'),
     url(r'^devices/(?P<pk>\d+)/interface-connections/add/$', views.interfaceconnection_add, name='interfaceconnection_add'),
     url(r'^interface-connections/(?P<pk>\d+)/delete/$', views.interfaceconnection_delete, name='interfaceconnection_delete'),

+ 16 - 0
netbox/dcim/views.py

@@ -457,6 +457,14 @@ class InterfaceTemplateAddView(ComponentTemplateCreateView):
     form = forms.InterfaceTemplateForm
 
 
+class InterfaceTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
+    permission_required = 'dcim.change_interfacetemplate'
+    cls = InterfaceTemplate
+    parent_cls = DeviceType
+    form = forms.InterfaceTemplateBulkEditForm
+    template_name = 'dcim/interfacetemplate_bulk_edit.html'
+
+
 class InterfaceTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_interfacetemplate'
     cls = InterfaceTemplate
@@ -1425,6 +1433,14 @@ class InterfaceBulkAddView(PermissionRequiredMixin, BulkEditView):
                                                                                       len(selected_devices)))
 
 
+class InterfaceBulkEditView(PermissionRequiredMixin, BulkEditView):
+    permission_required = 'dcim.change_interface'
+    cls = Interface
+    parent_cls = Device
+    form = forms.InterfaceBulkEditForm
+    template_name = 'dcim/interface_bulk_edit.html'
+
+
 class InterfaceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_interface'
     cls = Interface

+ 15 - 14
netbox/extras/forms.py

@@ -3,7 +3,7 @@ from collections import OrderedDict
 from django import forms
 from django.contrib.contenttypes.models import ContentType
 
-from utilities.forms import LaxURLField
+from utilities.forms import BulkEditForm, LaxURLField
 from .models import (
     CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL, CustomField, CustomFieldValue
 )
@@ -49,8 +49,6 @@ def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=F
         # Select
         elif cf.type == CF_TYPE_SELECT:
             choices = [(cfc.pk, cfc) for cfc in cf.choices.all()]
-            if not cf.required:
-                choices = [(0, 'None')] + choices
             if bulk_edit or filterable_only:
                 choices = [(None, '---------')] + choices
             field = forms.TypedChoiceField(choices=choices, coerce=int, required=cf.required)
@@ -73,10 +71,10 @@ def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=F
 
 
 class CustomFieldForm(forms.ModelForm):
-    custom_fields = []
 
     def __init__(self, *args, **kwargs):
 
+        self.custom_fields = []
         self.obj_type = ContentType.objects.get_for_model(self._meta.model)
 
         super(CustomFieldForm, self).__init__(*args, **kwargs)
@@ -126,22 +124,25 @@ class CustomFieldForm(forms.ModelForm):
         return obj
 
 
-class CustomFieldBulkEditForm(forms.Form):
-    custom_fields = []
-
-    def __init__(self, model, *args, **kwargs):
-
-        self.obj_type = ContentType.objects.get_for_model(model)
+class CustomFieldBulkEditForm(BulkEditForm):
 
+    def __init__(self, *args, **kwargs):
         super(CustomFieldBulkEditForm, self).__init__(*args, **kwargs)
 
+        self.custom_fields = []
+        self.obj_type = ContentType.objects.get_for_model(self.model)
+
         # Add all applicable CustomFields to the form
-        custom_fields = []
-        for name, field in get_custom_fields_for_model(self.obj_type, bulk_edit=True).items():
+        custom_fields = get_custom_fields_for_model(self.obj_type, bulk_edit=True).items()
+        for name, field in custom_fields:
+            # Annotate non-required custom fields as nullable
+            if not field.required:
+                self.nullable_fields.append(name)
             field.required = False
             self.fields[name] = field
-            custom_fields.append(name)
-        self.custom_fields = custom_fields
+            # Annotate this as a custom field
+            self.custom_fields.append(name)
+        print(self.nullable_fields)
 
 
 class CustomFieldFilterForm(forms.Form):

+ 22 - 19
netbox/ipam/forms.py

@@ -3,7 +3,6 @@ from django.db.models import Count
 
 from dcim.models import Site, Device, Interface
 from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
-from tenancy.forms import bulkedit_tenant_choices
 from tenancy.models import Tenant
 from utilities.forms import (
     APISelect, BootstrapMixin, CSVDataField, BulkImportForm, FilterChoiceField, Livesearch, SlugField,
@@ -23,18 +22,6 @@ IP_FAMILY_CHOICES = [
 ]
 
 
-def bulkedit_vrf_choices():
-    """
-    Include an option to assign the object to the global table.
-    """
-    choices = [
-        (None, '---------'),
-        (0, 'Global'),
-    ]
-    choices += [(v.pk, v.name) for v in VRF.objects.all()]
-    return choices
-
-
 #
 # VRFs
 #
@@ -67,9 +54,12 @@ class VRFImportForm(BulkImportForm, BootstrapMixin):
 
 class VRFBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
     pk = forms.ModelMultipleChoiceField(queryset=VRF.objects.all(), widget=forms.MultipleHiddenInput)
-    tenant = forms.TypedChoiceField(choices=bulkedit_tenant_choices, coerce=int, required=False, label='Tenant')
+    tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
     description = forms.CharField(max_length=100, required=False)
 
+    class Meta:
+        nullable_fields = ['tenant', 'description']
+
 
 class VRFFilterForm(BootstrapMixin, CustomFieldFilterForm):
     model = VRF
@@ -124,6 +114,9 @@ class AggregateBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
     date_added = forms.DateField(required=False)
     description = forms.CharField(max_length=100, required=False)
 
+    class Meta:
+        nullable_fields = ['date_added', 'description']
+
 
 class AggregateFilterForm(BootstrapMixin, CustomFieldFilterForm):
     model = Aggregate
@@ -253,12 +246,15 @@ class PrefixImportForm(BulkImportForm, BootstrapMixin):
 class PrefixBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
     pk = forms.ModelMultipleChoiceField(queryset=Prefix.objects.all(), widget=forms.MultipleHiddenInput)
     site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False)
-    vrf = forms.TypedChoiceField(choices=bulkedit_vrf_choices, coerce=int, required=False, label='VRF')
-    tenant = forms.TypedChoiceField(choices=bulkedit_tenant_choices, coerce=int, required=False, label='Tenant')
+    vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, label='VRF')
+    tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
     status = forms.ChoiceField(choices=FORM_PREFIX_STATUS_CHOICES, required=False)
     role = forms.ModelChoiceField(queryset=Role.objects.all(), required=False)
     description = forms.CharField(max_length=100, required=False)
 
+    class Meta:
+        nullable_fields = ['site', 'vrf', 'tenant', 'role', 'description']
+
 
 def prefix_status_choices():
     status_counts = {}
@@ -294,6 +290,7 @@ class IPAddressForm(BootstrapMixin, CustomFieldForm):
                                       widget=forms.Select(attrs={'filter-for': 'nat_device'}))
     nat_device = forms.ModelChoiceField(queryset=Device.objects.all(), required=False, label='Device',
                                         widget=APISelect(api_url='/api/dcim/devices/?site_id={{nat_site}}',
+                                                         display_field='display_name',
                                                          attrs={'filter-for': 'nat_inside'}))
     livesearch = forms.CharField(required=False, label='IP Address', widget=Livesearch(
         query_key='q', query_url='ipam-api:ipaddress_list', field_to_update='nat_inside', obj_label='address')
@@ -407,10 +404,13 @@ class IPAddressImportForm(BulkImportForm, BootstrapMixin):
 
 class IPAddressBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
     pk = forms.ModelMultipleChoiceField(queryset=IPAddress.objects.all(), widget=forms.MultipleHiddenInput)
-    vrf = forms.TypedChoiceField(choices=bulkedit_vrf_choices, coerce=int, required=False, label='VRF')
-    tenant = forms.TypedChoiceField(choices=bulkedit_tenant_choices, coerce=int, required=False, label='Tenant')
+    vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, label='VRF')
+    tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
     description = forms.CharField(max_length=100, required=False)
 
+    class Meta:
+        nullable_fields = ['vrf', 'tenant', 'description']
+
 
 class IPAddressFilterForm(BootstrapMixin, CustomFieldFilterForm):
     model = IPAddress
@@ -509,11 +509,14 @@ class VLANBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
     pk = forms.ModelMultipleChoiceField(queryset=VLAN.objects.all(), widget=forms.MultipleHiddenInput)
     site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False)
     group = forms.ModelChoiceField(queryset=VLANGroup.objects.all(), required=False)
-    tenant = forms.TypedChoiceField(choices=bulkedit_tenant_choices, coerce=int, required=False, label='Tenant')
+    tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
     status = forms.ChoiceField(choices=FORM_VLAN_STATUS_CHOICES, required=False)
     role = forms.ModelChoiceField(queryset=Role.objects.all(), required=False)
     description = forms.CharField(max_length=100, required=False)
 
+    class Meta:
+        nullable_fields = ['group', 'tenant', 'role', 'description']
+
 
 def vlan_status_choices():
     status_counts = {}

+ 1 - 1
netbox/ipam/models.py

@@ -139,7 +139,7 @@ class Aggregate(CreatedUpdatedModel, CustomFieldModel):
             if self.pk:
                 covered_aggregates = covered_aggregates.exclude(pk=self.pk)
             if covered_aggregates:
-                raise ValidationError("{} is overlaps with an existing aggregate ({})"
+                raise ValidationError("{} overlaps with an existing aggregate ({})"
                                       .format(self.prefix, covered_aggregates[0]))
 
     def save(self, *args, **kwargs):

+ 1 - 1
netbox/netbox/configuration.docker.py

@@ -9,7 +9,7 @@ import os
 # access to the server via any other hostnames. The first FQDN in the list will be treated as the preferred name.
 #
 # Example: ALLOWED_HOSTS = ['netbox.example.com', 'netbox.internal.local']
-ALLOWED_HOSTS = [os.environ.get('ALLOWED_HOSTS', '')]
+ALLOWED_HOSTS = os.environ.get('ALLOWED_HOSTS', '').split(' ')
 
 # PostgreSQL database configuration.
 DATABASE = {

+ 3 - 4
netbox/netbox/settings.py

@@ -12,7 +12,7 @@ except ImportError:
                                "the documentation.")
 
 
-VERSION = '1.6.2-r1'
+VERSION = '1.6.3'
 
 # Import local configuration
 for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']:
@@ -162,7 +162,7 @@ USE_TZ = True
 # Static files (CSS, JavaScript, Images)
 # https://docs.djangoproject.com/en/1.8/howto/static-files/
 STATIC_ROOT = BASE_DIR + '/static/'
-STATIC_URL = '/static/'
+STATIC_URL = '/{}static/'.format(BASE_PATH)
 STATICFILES_DIRS = (
     os.path.join(BASE_DIR, "project-static"),
 )
@@ -176,8 +176,7 @@ MESSAGE_TAGS = {
 }
 
 # Authentication URLs
-LOGIN_URL = '/login/'
-LOGIN_REDIRECT_URL = '/'
+LOGIN_URL = '/{}login/'.format(BASE_PATH)
 
 # Secrets
 SECRETS_MIN_PUBKEY_SIZE = 2048

+ 15 - 1
netbox/project-static/js/forms.js

@@ -1,6 +1,6 @@
 $(document).ready(function() {
 
-    // "Toggle all" checkbox in a table header
+    // "Toggle all" checkbox (table header)
     $('#toggle_all').click(function (event) {
         $('td input:checkbox[name=pk]').prop('checked', $(this).prop('checked'));
         if ($(this).is(':checked')) {
@@ -16,6 +16,15 @@ $(document).ready(function() {
         }
     });
 
+    // Simple "Toggle all" button (panel)
+    $('button.toggle').click(function (event) {
+        var selected = $(this).attr('selected');
+        $(this).closest('form').find('input:checkbox[name=pk]').prop('checked', !selected);
+        $(this).attr('selected', !selected);
+        $(this).children('span').toggleClass('glyphicon-unchecked glyphicon-check');
+        return false;
+    });
+
     // Slugify
     function slugify(s, num_chars) {
         s = s.replace(/[^\-\.\w\s]/g, '');          // Remove unneeded chars
@@ -37,6 +46,11 @@ $(document).ready(function() {
         })
     }
 
+    // Bulk edit nullification
+    $('input:checkbox[name=_nullify]').click(function (event) {
+        $('#id_' + this.value).toggle('disabled');
+    });
+
     // API select widget
     $('select[filter-for]').change(function () {
 

+ 5 - 2
netbox/secrets/forms.py

@@ -5,7 +5,7 @@ from django import forms
 from django.db.models import Count
 
 from dcim.models import Device
-from utilities.forms import BootstrapMixin, BulkImportForm, CSVDataField, FilterChoiceField, SlugField
+from utilities.forms import BootstrapMixin, BulkEditForm, BulkImportForm, CSVDataField, FilterChoiceField, SlugField
 
 from .models import Secret, SecretRole, UserKey
 
@@ -89,11 +89,14 @@ class SecretImportForm(BulkImportForm, BootstrapMixin):
     csv = CSVDataField(csv_form=SecretFromCSVForm, widget=forms.Textarea(attrs={'class': 'requires-private-key'}))
 
 
-class SecretBulkEditForm(forms.Form, BootstrapMixin):
+class SecretBulkEditForm(BulkEditForm, BootstrapMixin):
     pk = forms.ModelMultipleChoiceField(queryset=Secret.objects.all(), widget=forms.MultipleHiddenInput)
     role = forms.ModelChoiceField(queryset=SecretRole.objects.all(), required=False)
     name = forms.CharField(max_length=100, required=False)
 
+    class Meta:
+        nullable_fields = ['name']
+
 
 class SecretFilterForm(forms.Form, BootstrapMixin):
     role = FilterChoiceField(queryset=SecretRole.objects.annotate(filter_count=Count('secrets')), to_field_name='slug')

+ 35 - 18
netbox/templates/dcim/device.html

@@ -314,13 +314,16 @@
             <div class="panel panel-default">
                 <div class="panel-heading">
                     <strong>Device Bays</strong>
-                    {% if perms.dcim.add_devicebay and device_bays|length > 10 %}
-                        <div class="pull-right">
+                    <div class="pull-right">
+                        <button class="btn btn-default btn-xs toggle">
+                            <span class="glyphicon glyphicon-unchecked" aria-hidden="true"></span> Select all
+                        </button>
+                        {% if perms.dcim.add_devicebay and device_bays|length > 10 %}
                             <a href="{% url 'dcim:devicebay_add' pk=device.pk %}" class="btn btn-primary btn-xs">
                                 <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add device bays
                             </a>
-                        </div>
-                    {% endif %}
+                        {% endif %}
+                    </div>
                 </div>
                 <table class="table table-hover panel-body">
                     {% for devicebay in device_bays %}
@@ -355,19 +358,22 @@
         {% endif %}
         {% if interfaces or device.device_type.is_network_device %}
             {% if perms.dcim.delete_interface %}
-                <form method="post" action="{% url 'dcim:interface_bulk_delete' pk=device.pk %}">
+                <form method="post">
                 {% csrf_token %}
             {% endif %}
             <div class="panel panel-default">
                 <div class="panel-heading">
                     <strong>Interfaces</strong>
-                    {% if perms.dcim.add_interface and interfaces|length > 10 %}
-                        <div class="pull-right">
+                    <div class="pull-right">
+                        <button class="btn btn-default btn-xs toggle">
+                            <span class="glyphicon glyphicon-unchecked" aria-hidden="true"></span> Select all
+                        </button>
+                        {% if perms.dcim.add_interface and interfaces|length > 10 %}
                             <a href="{% url 'dcim:interface_add' pk=device.pk %}" class="btn btn-primary btn-xs">
                                 <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add interfaces
                             </a>
-                        </div>
-                    {% endif %}
+                        {% endif %}
+                    </div>
                 </div>
                 <table class="table table-hover panel-body">
                     {% for iface in interfaces %}
@@ -380,8 +386,13 @@
                 </table>
                 {% if perms.dcim.add_interface or perms.dcim.delete_interface %}
                     <div class="panel-footer">
+                        {% if interfaces and perms.dcim.change_interface %}
+                            <button type="submit" name="_edit" formaction="{% url 'dcim:interface_bulk_edit' pk=device.pk %}" class="btn btn-warning btn-xs">
+                                <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit selected
+                            </button>
+                        {% endif %}
                         {% if interfaces and perms.dcim.delete_interface %}
-                            <button type="submit" class="btn btn-danger btn-xs">
+                            <button type="submit" name="_delete" formaction="{% url 'dcim:interface_bulk_delete' pk=device.pk %}" class="btn btn-danger btn-xs">
                                 <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete selected
                             </button>
                         {% endif %}
@@ -408,13 +419,16 @@
             <div class="panel panel-default">
                 <div class="panel-heading">
                     <strong>Console Server Ports</strong>
-                    {% if perms.dcim.add_consoleserverport and cs_ports|length > 10 %}
-                        <div class="pull-right">
+                    <div class="pull-right">
+                        <button class="btn btn-default btn-xs toggle">
+                            <span class="glyphicon glyphicon-unchecked" aria-hidden="true"></span> Select all
+                        </button>
+                        {% if perms.dcim.add_consoleserverport and cs_ports|length > 10 %}
                             <a href="{% url 'dcim:consoleserverport_add' pk=device.pk %}" class="btn btn-primary btn-xs">
                                 <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add console server ports
                             </a>
-                        </div>
-                    {% endif %}
+                        {% endif %}
+                    </div>
                 </div>
                 <table class="table table-hover panel-body">
                     {% for csp in cs_ports %}
@@ -455,13 +469,16 @@
             <div class="panel panel-default">
                 <div class="panel-heading">
                     <strong>Power Outlets</strong>
-                    {% if perms.dcim.add_poweroutlet and power_outlets|length > 10 %}
-                        <div class="pull-right">
+                    <div class="pull-right">
+                        <button class="btn btn-default btn-xs toggle">
+                            <span class="glyphicon glyphicon-unchecked" aria-hidden="true"></span> Select all
+                        </button>
+                        {% if perms.dcim.add_poweroutlet and power_outlets|length > 10 %}
                             <a href="{% url 'dcim:poweroutlet_add' pk=device.pk %}" class="btn btn-primary btn-xs">
                                 <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add power outlets
                             </a>
-                        </div>
-                    {% endif %}
+                        {% endif %}
+                    </div>
                 </div>
                 <table class="table table-hover panel-body">
                     {% for po in power_outlets %}

+ 6 - 2
netbox/templates/dcim/devicetype.html

@@ -72,6 +72,10 @@
                         {% endif %}
                     </td>
                 </tr>
+                <tr>
+                    <td>Instances</td>
+                    <td><a href="{% url 'dcim:device_list' %}?device_type_id={{ devicetype.pk }}">{{ devicetype.instances.count }}</a></td>
+                </tr>
             </table>
         </div>
         <div class="panel panel-default">
@@ -143,14 +147,14 @@
         </div>
         {% include 'dcim/inc/devicetype_component_table.html' with table=consoleport_table title='Console Ports' add_url='dcim:devicetype_add_consoleport' delete_url='dcim:devicetype_delete_consoleport' %}
         {% include 'dcim/inc/devicetype_component_table.html' with table=powerport_table title='Power Ports' add_url='dcim:devicetype_add_powerport' delete_url='dcim:devicetype_delete_powerport' %}
-        {% include 'dcim/inc/devicetype_component_table.html' with table=mgmt_interface_table title='Management Interfaces' add_url='dcim:devicetype_add_interface' add_url_extra='?mgmt_only=1' delete_url='dcim:devicetype_delete_interface' %}
+        {% include 'dcim/inc/devicetype_component_table.html' with table=mgmt_interface_table title='Management Interfaces' add_url='dcim:devicetype_add_interface' add_url_extra='?mgmt_only=1' edit_url='dcim:devicetype_bulkedit_interface' delete_url='dcim:devicetype_delete_interface' %}
     </div>
     <div class="col-md-6">
         {% if devicetype.is_parent_device %}
             {% include 'dcim/inc/devicetype_component_table.html' with table=devicebay_table title='Device Bays' add_url='dcim:devicetype_add_devicebay' delete_url='dcim:devicetype_delete_devicebay' %}
         {% endif %}
         {% if devicetype.is_network_device %}
-            {% include 'dcim/inc/devicetype_component_table.html' with table=interface_table title='Interfaces' add_url='dcim:devicetype_add_interface' delete_url='dcim:devicetype_delete_interface' %}
+            {% include 'dcim/inc/devicetype_component_table.html' with table=interface_table title='Interfaces' add_url='dcim:devicetype_add_interface' edit_url='dcim:devicetype_bulkedit_interface' delete_url='dcim:devicetype_delete_interface' %}
         {% endif %}
         {% if devicetype.is_console_server %}
             {% include 'dcim/inc/devicetype_component_table.html' with table=consoleserverport_table title='Console Server Ports' add_url='dcim:devicetype_add_consoleserverport' delete_url='dcim:devicetype_delete_consoleserverport' %}

+ 20 - 8
netbox/templates/dcim/inc/devicetype_component_table.html

@@ -1,25 +1,37 @@
 {% load render_table from django_tables2 %}
 {% if perms.dcim.change_devicetype %}
-    <form method="post" action="{% url delete_url pk=devicetype.pk %}">
+    <form method="post">
         {% csrf_token %}
         <div class="panel panel-default">
             <div class="panel-heading">
                 <strong>{{ title }}</strong>
-                {% if table.rows|length > 10 %}
-                    <div class="pull-right">
+                <div class="pull-right">
+                    {% if table.rows|length > 3 %}
+                        <button class="btn btn-default btn-xs toggle">
+                            <span class="glyphicon glyphicon-unchecked" aria-hidden="true"></span> Select all
+                        </button>
+                    {% endif %}
+                    {% if table.rows|length > 10 %}
                         <a href="{% url add_url pk=devicetype.pk %}{{ add_url_extra }}" class="btn btn-primary btn-xs">
                             <span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
                             Add {{ title }}
                         </a>
-                    </div>
-                {% endif %}
+                    {% endif %}
+                </div>
             </div>
             {% render_table table 'table.html' %}
             <div class="panel-footer">
                 {% if table.rows %}
-                    <button type="submit" class="btn btn-xs btn-danger">
-                        <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete Selected
-                    </button>
+                    {% if edit_url %}
+                        <button type="submit" name="_edit" formaction="{% url edit_url pk=devicetype.pk %}" class="btn btn-xs btn-warning">
+                            <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit Selected
+                        </button>
+                    {% endif %}
+                    {% if delete_url %}
+                        <button type="submit" name="_delete" formaction="{% url delete_url pk=devicetype.pk %}" class="btn btn-xs btn-danger">
+                            <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete Selected
+                        </button>
+                    {% endif %}
                 {% endif %}
                 <div class="pull-right">
                     <a href="{% url add_url pk=devicetype.pk %}{{ add_url_extra }}" class="btn btn-primary btn-xs">

+ 17 - 0
netbox/templates/dcim/interface_bulk_edit.html

@@ -0,0 +1,17 @@
+{% extends 'utilities/bulk_edit_form.html' %}
+{% load form_helpers %}
+
+{% block title %}Interface Bulk Edit{% endblock %}
+
+{% block selected_objects_table %}
+    <tr>
+        <th>Name</th>
+        <th>Form Factor</th>
+    </tr>
+    {% for iface in selected_objects %}
+        <tr>
+            <td>{{ iface.name }}</td>
+            <td>{{ iface.get_form_factor_display }}</td>
+        </tr>
+    {% endfor %}
+{% endblock %}

+ 25 - 0
netbox/templates/dcim/interfacetemplate_bulk_edit.html

@@ -0,0 +1,25 @@
+{% extends 'utilities/bulk_edit_form.html' %}
+{% load form_helpers %}
+
+{% block title %}Interface Template Bulk Edit{% endblock %}
+
+{% block selected_objects_table %}
+    <tr>
+        <th>Name</th>
+        <th>Form Factor</th>
+        <th>Management</th>
+    </tr>
+    {% for iface in selected_objects %}
+        <tr>
+            <td>{{ iface.name }}</td>
+            <td>{{ iface.get_form_factor_display }}</td>
+            <td>
+                {% if iface.mgmt_only %}
+                    <i class="glyphicon glyphicon-ok text-success" title="Yes"></i>
+                {% else %}
+                    <i class="glyphicon glyphicon-remove text-danger" title="No"></i>
+                {% endif %}
+            </td>
+        </tr>
+    {% endfor %}
+{% endblock %}

+ 4 - 4
netbox/templates/inc/custom_fields_panel.html

@@ -8,13 +8,13 @@
                 <tr>
                     <td>{{ field }}</td>
                     <td>
-                        {% if value == True %}
+                        {% if field.type == 300 and value == True %}
                             <i class="glyphicon glyphicon-ok text-success" title="True"></i>
-                        {% elif value == False %}
+                        {% elif field.type == 300 and value == False %}
                             <i class="glyphicon glyphicon-remove text-danger" title="False"></i>
                         {% elif field.type == 500 and value %}
-                            {{ value|urlizetrunc:75 }}
-                        {% elif value %}
+                            <a href="{{ value }}">{{ value|truncatechars:70 }}</a>
+                        {% elif field.type == 200 or value %}
                             {{ value }}
                         {% elif field.required %}
                             <span class="text-warning">Not defined</span>

+ 10 - 1
netbox/templates/utilities/bulk_edit_form.html

@@ -8,6 +8,9 @@
     {% if request.POST.redirect_url %}
         <input type="hidden" name="redirect_url" value="{{ request.POST.redirect_url }}" />
     {% endif %}
+    {% for field in form.hidden_fields %}
+        {{ field }}
+    {% endfor %}
     <div class="row">
         <div class="col-md-7">
             <div class="panel panel-default">
@@ -29,7 +32,13 @@
             <div class="panel panel-default">
                 <div class="panel-heading"><strong>{% block form_title %}Attributes{% endblock %}</strong></div>
                 <div class="panel-body">
-                    {% render_form form %}
+                    {% for field in form.visible_fields %}
+                        {% if field.name in form.nullable_fields %}
+                            {% render_field field bulk_nullable=True %}
+                        {% else %}
+                            {% render_field field %}
+                        {% endif %}
+                    {% endfor %}
                 </div>
             </div>
 		    <div class="form-group text-right">

+ 15 - 10
netbox/templates/utilities/render_field.html

@@ -5,26 +5,26 @@
         <div class="col-md-9 col-md-offset-3">
             <div class="checkbox{% if field.errors %} has-error{% endif %}">
                 <label for="{{ field.id_for_label }}">
-                    {{ field }}
-                    {{ field.label }}
+                    {{ field }} {{ field.label }}
                 </label>
                 {% if field.help_text %}
                     <span class="help-block">{{ field.help_text|safe }}</span>
                 {% endif %}
             </div>
-        </div>
-    {% elif field|widget_type == 'radioselect' %}
-        <div class="col-md-9 col-md-offset-3">
-            <div class="radio{% if field.errors %} has-error{% endif %}">
-                <label for="{{ field.id_for_label }}">
-                    {{ field }}
-                    {{ field.label }}
+            {% if bulk_nullable %}
+                <label class="checkbox-inline">
+                    <input type="checkbox" name="_nullify" value="{{ field.name }}" /> Set null
                 </label>
-            </div>
+            {% endif %}
         </div>
     {% elif field|widget_type == 'textarea' %}
         <div class="col-md-12">
             {{ field }}
+            {% if bulk_nullable %}
+                <label class="checkbox-inline">
+                    <input type="checkbox" name="_nullify" value="{{ field.name }}" /> Set null
+                </label>
+            {% endif %}
             {% if field.help_text %}
                 <span class="help-block">{{ field.help_text|safe }}</span>
             {% endif %}
@@ -40,6 +40,11 @@
         <label class="col-md-3 control-label{% if field.field.required %} required{% endif %}" for="{{ field.id_for_label }}">{{ field.label }}</label>
         <div class="col-md-9">
             {{ field }}
+            {% if bulk_nullable %}
+                <label class="checkbox-inline">
+                    <input type="checkbox" name="_nullify" value="{{ field.name }}" /> Set null
+                </label>
+            {% endif %}
             {% if field.help_text %}
                 <span class="help-block">{{ field.help_text|safe }}</span>
             {% endif %}

+ 4 - 25
netbox/tenancy/forms.py

@@ -7,30 +7,6 @@ from utilities.forms import BootstrapMixin, BulkImportForm, CommentField, CSVDat
 from .models import Tenant, TenantGroup
 
 
-def bulkedit_tenantgroup_choices():
-    """
-    Include an option to remove the currently assigned TenantGroup from a Tenant.
-    """
-    choices = [
-        (None, '---------'),
-        (0, 'None'),
-    ]
-    choices += [(g.pk, g.name) for g in TenantGroup.objects.all()]
-    return choices
-
-
-def bulkedit_tenant_choices():
-    """
-    Include an option to remove the currently assigned Tenant from an object.
-    """
-    choices = [
-        (None, '---------'),
-        (0, 'None'),
-    ]
-    choices += [(t.pk, t.name) for t in Tenant.objects.all()]
-    return choices
-
-
 #
 # Tenant groups
 #
@@ -71,7 +47,10 @@ class TenantImportForm(BulkImportForm, BootstrapMixin):
 
 class TenantBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
     pk = forms.ModelMultipleChoiceField(queryset=Tenant.objects.all(), widget=forms.MultipleHiddenInput)
-    group = forms.TypedChoiceField(choices=bulkedit_tenantgroup_choices, coerce=int, required=False, label='Group')
+    group = forms.ModelChoiceField(queryset=TenantGroup.objects.all(), required=False)
+
+    class Meta:
+        nullable_fields = ['group']
 
 
 class TenantFilterForm(BootstrapMixin, CustomFieldFilterForm):

+ 2 - 3
netbox/users/views.py

@@ -1,10 +1,9 @@
-from django.conf import settings
 from django.contrib import messages
 from django.contrib.auth import login as auth_login, logout as auth_logout, update_session_auth_hash
 from django.contrib.auth.decorators import login_required
 from django.core.urlresolvers import reverse
 from django.http import HttpResponseRedirect
-from django.shortcuts import redirect, render, resolve_url
+from django.shortcuts import redirect, render
 from django.utils.http import is_safe_url
 
 from secrets.forms import UserKeyForm
@@ -26,7 +25,7 @@ def login(request):
             # Determine where to direct user after successful login
             redirect_to = request.POST.get('next', '')
             if not is_safe_url(url=redirect_to, host=request.get_host()):
-                redirect_to = resolve_url(settings.LOGIN_REDIRECT_URL)
+                redirect_to = reverse('home')
 
             # Authenticate user
             auth_login(request, form.get_user())

+ 13 - 1
netbox/utilities/forms.py

@@ -34,7 +34,7 @@ def add_blank_choice(choices):
     """
     Add a blank choice to the beginning of a choices list.
     """
-    return ((None, '---------'),) + choices
+    return ((None, '---------'),) + tuple(choices)
 
 
 #
@@ -294,6 +294,18 @@ class ConfirmationForm(forms.Form, BootstrapMixin):
     confirm = forms.BooleanField(required=True)
 
 
+class BulkEditForm(forms.Form):
+
+    def __init__(self, model, *args, **kwargs):
+        super(BulkEditForm, self).__init__(*args, **kwargs)
+        self.model = model
+        # Copy any nullable fields defined in Meta
+        if hasattr(self.Meta, 'nullable_fields'):
+            self.nullable_fields = [field for field in self.Meta.nullable_fields]
+        else:
+            self.nullable_fields = []
+
+
 class BulkImportForm(forms.Form):
 
     def clean(self):

+ 1 - 1
netbox/utilities/middleware.py

@@ -12,4 +12,4 @@ class LoginRequiredMiddleware:
     def process_request(self, request):
         if LOGIN_REQUIRED and not request.user.is_authenticated():
             if request.path_info != settings.LOGIN_URL:
-                return HttpResponseRedirect(settings.LOGIN_URL)
+                return HttpResponseRedirect('{}?next={}'.format(settings.LOGIN_URL, request.path_info))

+ 2 - 1
netbox/utilities/templatetags/form_helpers.py

@@ -5,12 +5,13 @@ register = template.Library()
 
 
 @register.inclusion_tag('utilities/render_field.html')
-def render_field(field):
+def render_field(field, bulk_nullable=False):
     """
     Render a single form field from template
     """
     return {
         'field': field,
+        'bulk_nullable': bulk_nullable,
     }
 
 

+ 42 - 31
netbox/utilities/views.py

@@ -280,42 +280,59 @@ class BulkImportView(View):
 
 class BulkEditView(View):
     cls = None
+    parent_cls = None
     form = None
     template_name = None
     default_redirect_url = None
 
-    def get(self, request, *args, **kwargs):
+    def get(self):
         return redirect(self.default_redirect_url)
 
-    def post(self, request, *args, **kwargs):
+    def post(self, request, **kwargs):
 
+        # Attempt to derive parent object if a parent class has been given
+        if self.parent_cls:
+            parent_obj = get_object_or_404(self.parent_cls, **kwargs)
+        else:
+            parent_obj = None
+
+        # Determine URL to redirect users upon modification of objects
         posted_redirect_url = request.POST.get('redirect_url')
         if posted_redirect_url and is_safe_url(url=posted_redirect_url, host=request.get_host()):
             redirect_url = posted_redirect_url
-        else:
+        elif parent_obj:
+            redirect_url = parent_obj.get_absolute_url()
+        elif self.default_redirect_url:
             redirect_url = reverse(self.default_redirect_url)
+        else:
+            raise ImproperlyConfigured('No redirect URL has been provided.')
 
+        # Are we editing *all* objects in the queryset or just a selected subset?
         if request.POST.get('_all'):
             pk_list = [int(pk) for pk in request.POST.get('pk_all').split(',') if pk]
         else:
             pk_list = [int(pk) for pk in request.POST.getlist('pk')]
 
         if '_apply' in request.POST:
-            if hasattr(self.form, 'custom_fields'):
-                form = self.form(self.cls, request.POST)
-            else:
-                form = self.form(request.POST)
+            form = self.form(self.cls, request.POST)
             if form.is_valid():
 
                 custom_fields = form.custom_fields if hasattr(form, 'custom_fields') else []
                 standard_fields = [field for field in form.fields if field not in custom_fields and field != 'pk']
 
-                # Update objects
-                updated_count = self.update_objects(pk_list, form, standard_fields)
+                # Update standard fields. If a field is listed in _nullify, delete its value.
+                nullified_fields = request.POST.getlist('_nullify')
+                fields_to_update = {}
+                for field in standard_fields:
+                    if field in form.nullable_fields and field in nullified_fields:
+                        fields_to_update[field] = ''
+                    elif form.cleaned_data[field]:
+                        fields_to_update[field] = form.cleaned_data[field]
+                updated_count = self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update)
 
                 # Update custom fields for objects
                 if custom_fields:
-                    objs_updated = self.update_custom_fields(pk_list, form, custom_fields)
+                    objs_updated = self.update_custom_fields(pk_list, form, custom_fields, nullified_fields)
                     if objs_updated and not updated_count:
                         updated_count = objs_updated
 
@@ -326,10 +343,7 @@ class BulkEditView(View):
                 return redirect(redirect_url)
 
         else:
-            if hasattr(self.form, 'custom_fields'):
-                form = self.form(self.cls, initial={'pk': pk_list})
-            else:
-                form = self.form(initial={'pk': pk_list})
+            form = self.form(self.cls, initial={'pk': pk_list})
 
         selected_objects = self.cls.objects.filter(pk__in=pk_list)
         if not selected_objects:
@@ -342,26 +356,23 @@ class BulkEditView(View):
             'cancel_url': redirect_url,
         })
 
-    def update_objects(self, pk_list, form, fields):
-        fields_to_update = {}
+    def update_custom_fields(self, pk_list, form, fields, nullified_fields):
+        obj_type = ContentType.objects.get_for_model(self.cls)
+        objs_updated = False
 
         for name in fields:
-            # Check for zero value (bulk editing)
-            if isinstance(form.fields[name], TypedChoiceField) and form.cleaned_data[name] == 0:
-                fields_to_update[name] = None
-            elif form.cleaned_data[name]:
-                fields_to_update[name] = form.cleaned_data[name]
 
-        return self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update)
+            field = form.fields[name].model
 
-    def update_custom_fields(self, pk_list, form, fields):
-        obj_type = ContentType.objects.get_for_model(self.cls)
-        objs_updated = False
+            # Setting the field to null
+            if name in form.nullable_fields and name in nullified_fields:
 
-        for name in fields:
-            if form.cleaned_data[name] not in [None, u'']:
+                # Delete all CustomFieldValues for instances of this field belonging to the selected objects.
+                CustomFieldValue.objects.filter(field=field, obj_type=obj_type, obj_id__in=pk_list).delete()
+                objs_updated = True
 
-                field = form.fields[name].model
+            # Updating the value of the field
+            elif form.cleaned_data[name] not in [None, u'']:
 
                 # Check for zero value (bulk editing)
                 if isinstance(form.fields[name], TypedChoiceField) and form.cleaned_data[name] == 0:
@@ -400,7 +411,7 @@ class BulkDeleteView(View):
     template_name = 'utilities/confirm_bulk_delete.html'
     default_redirect_url = None
 
-    def post(self, request, *args, **kwargs):
+    def post(self, request, **kwargs):
 
         # Attempt to derive parent object if a parent class has been given
         if self.parent_cls:
@@ -421,9 +432,9 @@ class BulkDeleteView(View):
 
         # Are we deleting *all* objects in the queryset or just a selected subset?
         if request.POST.get('_all'):
-            pk_list = [x for x in request.POST.get('pk_all').split(',') if x]
+            pk_list = [int(pk) for pk in request.POST.get('pk_all').split(',') if pk]
         else:
-            pk_list = request.POST.getlist('pk')
+            pk_list = [int(pk) for pk in request.POST.getlist('pk')]
 
         form_cls = self.get_form()