Просмотр исходного кода

Merge pull request #569 from digitalocean/develop

Release v1.6.1
Jeremy Stretch 9 лет назад
Родитель
Сommit
b99704082b

+ 4 - 1
Dockerfile

@@ -5,7 +5,10 @@ WORKDIR /opt/netbox
 ARG BRANCH=master
 ARG URL=https://github.com/digitalocean/netbox.git
 RUN git clone --depth 1 $URL -b $BRANCH .  && \
-	pip install gunicorn==17.5 && pip install -r requirements.txt
+    apt-get update -qq && apt-get install -y libldap2-dev libsasl2-dev libssl-dev && \
+	pip install gunicorn==17.5 && \
+	pip install django-auth-ldap && \
+    pip install -r requirements.txt
 
 ADD docker/docker-entrypoint.sh /docker-entrypoint.sh
 ADD netbox/netbox/configuration.docker.py /opt/netbox/netbox/netbox/configuration.py

+ 2 - 0
docs/data-model/extras.md

@@ -35,6 +35,8 @@ Each export template is associated with a certain type of object. For instance,
 
 Export templates are written in [Django's template language](https://docs.djangoproject.com/en/1.9/ref/templates/language/), which is very similar to Jinja2. The list of objects returned from the database is stored in the `queryset` variable. Typically, you'll want to iterate through this list using a for loop.
 
+To access custom fields of an object within a template, use the `cf` attribute. For example, `{{ obj.cf.color }}` will return the value (if any) for a custom field named `color` on `obj`.
+
 A MIME type and file extension can optionally be defined for each export template. The default MIME type is `text/plain`.
 
 ## Example

+ 4 - 2
netbox/circuits/filters.py

@@ -5,6 +5,8 @@ from django.db.models import Q
 from dcim.models import Site
 from extras.filters import CustomFieldFilterSet
 from tenancy.models import Tenant
+from utilities.filters import NullableModelMultipleChoiceFilter
+
 from .models import Provider, Circuit, CircuitType
 
 
@@ -64,12 +66,12 @@ class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet):
         to_field_name='slug',
         label='Circuit type (slug)',
     )
-    tenant_id = django_filters.ModelMultipleChoiceFilter(
+    tenant_id = NullableModelMultipleChoiceFilter(
         name='tenant',
         queryset=Tenant.objects.all(),
         label='Tenant (ID)',
     )
-    tenant = django_filters.ModelMultipleChoiceFilter(
+    tenant = NullableModelMultipleChoiceFilter(
         name='tenant',
         queryset=Tenant.objects.all(),
         to_field_name='slug',

+ 10 - 35
netbox/circuits/forms.py

@@ -6,7 +6,8 @@ from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFi
 from tenancy.forms import bulkedit_tenant_choices
 from tenancy.models import Tenant
 from utilities.forms import (
-    APISelect, BootstrapMixin, BulkImportForm, CommentField, CSVDataField, Livesearch, SmallTextarea, SlugField,
+    APISelect, BootstrapMixin, BulkImportForm, CommentField, CSVDataField, FilterChoiceField, Livesearch, SmallTextarea,
+    SlugField,
 )
 
 from .models import Circuit, CircuitType, Provider
@@ -57,15 +58,9 @@ class ProviderBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
     comments = CommentField()
 
 
-def provider_site_choices():
-    site_choices = Site.objects.all()
-    return [(s.slug, s.name) for s in site_choices]
-
-
 class ProviderFilterForm(BootstrapMixin, CustomFieldFilterForm):
     model = Provider
-    site = forms.MultipleChoiceField(required=False, choices=provider_site_choices,
-                                     widget=forms.SelectMultiple(attrs={'size': 8}))
+    site = FilterChoiceField(queryset=Site.objects.all(), to_field_name='slug')
 
 
 #
@@ -189,32 +184,12 @@ class CircuitBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
     comments = CommentField()
 
 
-def circuit_type_choices():
-    type_choices = CircuitType.objects.annotate(circuit_count=Count('circuits'))
-    return [(t.slug, u'{} ({})'.format(t.name, t.circuit_count)) for t in type_choices]
-
-
-def circuit_provider_choices():
-    provider_choices = Provider.objects.annotate(circuit_count=Count('circuits'))
-    return [(p.slug, u'{} ({})'.format(p.name, p.circuit_count)) for p in provider_choices]
-
-
-def circuit_tenant_choices():
-    tenant_choices = Tenant.objects.annotate(circuit_count=Count('circuits'))
-    return [(t.slug, u'{} ({})'.format(t.name, t.circuit_count)) for t in tenant_choices]
-
-
-def circuit_site_choices():
-    site_choices = Site.objects.annotate(circuit_count=Count('circuits'))
-    return [(s.slug, u'{} ({})'.format(s.name, s.circuit_count)) for s in site_choices]
-
-
 class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm):
     model = Circuit
-    type = forms.MultipleChoiceField(required=False, choices=circuit_type_choices)
-    provider = forms.MultipleChoiceField(required=False, choices=circuit_provider_choices,
-                                         widget=forms.SelectMultiple(attrs={'size': 8}))
-    tenant = forms.MultipleChoiceField(required=False, choices=circuit_tenant_choices,
-                                       widget=forms.SelectMultiple(attrs={'size': 8}))
-    site = forms.MultipleChoiceField(required=False, choices=circuit_site_choices,
-                                     widget=forms.SelectMultiple(attrs={'size': 8}))
+    type = FilterChoiceField(queryset=CircuitType.objects.annotate(filter_count=Count('circuits')),
+                             to_field_name='slug')
+    provider = FilterChoiceField(queryset=Provider.objects.annotate(filter_count=Count('circuits')),
+                                 to_field_name='slug')
+    tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('circuits')), to_field_name='slug',
+                               null_option=(0, 'None'))
+    site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('circuits')), to_field_name='slug')

+ 13 - 12
netbox/dcim/filters.py

@@ -4,6 +4,7 @@ from django.db.models import Q
 
 from extras.filters import CustomFieldFilterSet
 from tenancy.models import Tenant
+from utilities.filters import NullableModelMultipleChoiceFilter
 from .models import (
     ConsolePort, ConsoleServerPort, Device, DeviceRole, DeviceType, Interface, InterfaceConnection, Manufacturer,
     Platform, PowerOutlet, PowerPort, Rack, RackGroup, RackRole, Site,
@@ -15,12 +16,12 @@ class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet):
         action='search',
         label='Search',
     )
-    tenant_id = django_filters.ModelMultipleChoiceFilter(
+    tenant_id = NullableModelMultipleChoiceFilter(
         name='tenant',
         queryset=Tenant.objects.all(),
         label='Tenant (ID)',
     )
-    tenant = django_filters.ModelMultipleChoiceFilter(
+    tenant = NullableModelMultipleChoiceFilter(
         name='tenant',
         queryset=Tenant.objects.all(),
         to_field_name='slug',
@@ -75,34 +76,34 @@ class RackFilter(CustomFieldFilterSet, django_filters.FilterSet):
         to_field_name='slug',
         label='Site (slug)',
     )
-    group_id = django_filters.ModelMultipleChoiceFilter(
+    group_id = NullableModelMultipleChoiceFilter(
         name='group',
         queryset=RackGroup.objects.all(),
         label='Group (ID)',
     )
-    group = django_filters.ModelMultipleChoiceFilter(
+    group = NullableModelMultipleChoiceFilter(
         name='group',
         queryset=RackGroup.objects.all(),
         to_field_name='slug',
         label='Group',
     )
-    tenant_id = django_filters.ModelMultipleChoiceFilter(
+    tenant_id = NullableModelMultipleChoiceFilter(
         name='tenant',
         queryset=Tenant.objects.all(),
         label='Tenant (ID)',
     )
-    tenant = django_filters.ModelMultipleChoiceFilter(
+    tenant = NullableModelMultipleChoiceFilter(
         name='tenant',
         queryset=Tenant.objects.all(),
         to_field_name='slug',
         label='Tenant (slug)',
     )
-    role_id = django_filters.ModelMultipleChoiceFilter(
+    role_id = NullableModelMultipleChoiceFilter(
         name='role',
         queryset=RackRole.objects.all(),
         label='Role (ID)',
     )
-    role = django_filters.ModelMultipleChoiceFilter(
+    role = NullableModelMultipleChoiceFilter(
         name='role',
         queryset=RackRole.objects.all(),
         to_field_name='slug',
@@ -177,12 +178,12 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
         to_field_name='slug',
         label='Role (slug)',
     )
-    tenant_id = django_filters.ModelMultipleChoiceFilter(
+    tenant_id = NullableModelMultipleChoiceFilter(
         name='tenant',
         queryset=Tenant.objects.all(),
         label='Tenant (ID)',
     )
-    tenant = django_filters.ModelMultipleChoiceFilter(
+    tenant = NullableModelMultipleChoiceFilter(
         name='tenant',
         queryset=Tenant.objects.all(),
         to_field_name='slug',
@@ -210,12 +211,12 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
         to_field_name='slug',
         label='Device model (slug)',
     )
-    platform_id = django_filters.ModelMultipleChoiceFilter(
+    platform_id = NullableModelMultipleChoiceFilter(
         name='platform',
         queryset=Platform.objects.all(),
         label='Platform (ID)',
     )
-    platform = django_filters.ModelMultipleChoiceFilter(
+    platform = NullableModelMultipleChoiceFilter(
         name='platform',
         queryset=Platform.objects.all(),
         to_field_name='slug',

+ 23 - 91
netbox/dcim/forms.py

@@ -9,7 +9,7 @@ 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,
-    FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea, SlugField,
+    FilterChoiceField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea, SlugField,
 )
 
 from .models import (
@@ -117,15 +117,10 @@ class SiteBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
     tenant = forms.TypedChoiceField(choices=bulkedit_tenant_choices, coerce=int, required=False, label='Tenant')
 
 
-def site_tenant_choices():
-    tenant_choices = Tenant.objects.annotate(site_count=Count('sites'))
-    return [(t.slug, u'{} ({})'.format(t.name, t.site_count)) for t in tenant_choices]
-
-
 class SiteFilterForm(BootstrapMixin, CustomFieldFilterForm):
     model = Site
-    tenant = forms.MultipleChoiceField(required=False, choices=site_tenant_choices,
-                                       widget=forms.SelectMultiple(attrs={'size': 8}))
+    tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('sites')), to_field_name='slug',
+                               null_option=(0, 'None'))
 
 
 #
@@ -140,14 +135,8 @@ class RackGroupForm(forms.ModelForm, BootstrapMixin):
         fields = ['site', 'name', 'slug']
 
 
-def rackgroup_site_choices():
-    site_choices = Site.objects.annotate(rack_count=Count('rack_groups'))
-    return [(s.slug, u'{} ({})'.format(s.name, s.rack_count)) for s in site_choices]
-
-
 class RackGroupFilterForm(forms.Form, BootstrapMixin):
-    site = forms.MultipleChoiceField(required=False, choices=rackgroup_site_choices,
-                                     widget=forms.SelectMultiple(attrs={'size': 8}))
+    site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('rack_groups')), to_field_name='slug')
 
 
 #
@@ -254,36 +243,15 @@ class RackBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
     comments = CommentField()
 
 
-def rack_site_choices():
-    site_choices = Site.objects.annotate(rack_count=Count('racks'))
-    return [(s.slug, u'{} ({})'.format(s.name, s.rack_count)) for s in site_choices]
-
-
-def rack_group_choices():
-    group_choices = RackGroup.objects.select_related('site').annotate(rack_count=Count('racks'))
-    return [(g.pk, u'{} ({})'.format(g, g.rack_count)) for g in group_choices]
-
-
-def rack_tenant_choices():
-    tenant_choices = Tenant.objects.annotate(rack_count=Count('racks'))
-    return [(t.slug, u'{} ({})'.format(t.name, t.rack_count)) for t in tenant_choices]
-
-
-def rack_role_choices():
-    role_choices = RackRole.objects.annotate(rack_count=Count('racks'))
-    return [(r.slug, u'{} ({})'.format(r.name, r.rack_count)) for r in role_choices]
-
-
 class RackFilterForm(BootstrapMixin, CustomFieldFilterForm):
     model = Rack
-    site = forms.MultipleChoiceField(required=False, choices=rack_site_choices,
-                                     widget=forms.SelectMultiple(attrs={'size': 8}))
-    group_id = forms.MultipleChoiceField(required=False, choices=rack_group_choices, label='Rack Group',
-                                         widget=forms.SelectMultiple(attrs={'size': 8}))
-    tenant = forms.MultipleChoiceField(required=False, choices=rack_tenant_choices,
-                                       widget=forms.SelectMultiple(attrs={'size': 8}))
-    role = forms.MultipleChoiceField(required=False, choices=rack_role_choices,
-                                     widget=forms.SelectMultiple(attrs={'size': 8}))
+    site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('racks')), to_field_name='slug')
+    group_id = FilterChoiceField(queryset=RackGroup.objects.select_related('site')
+                                 .annotate(filter_count=Count('racks')), label='Rack group', null_option=(0, 'None'))
+    tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('racks')), to_field_name='slug',
+                               null_option=(0, 'None'))
+    role = FilterChoiceField(queryset=RackRole.objects.annotate(filter_count=Count('racks')), to_field_name='slug',
+                             null_option=(0, 'None'))
 
 
 #
@@ -317,14 +285,9 @@ class DeviceTypeBulkEditForm(forms.Form, BootstrapMixin):
     u_height = forms.IntegerField(min_value=1, required=False)
 
 
-def devicetype_manufacturer_choices():
-    manufacturer_choices = Manufacturer.objects.annotate(devicetype_count=Count('device_types'))
-    return [(m.slug, u'{} ({})'.format(m.name, m.devicetype_count)) for m in manufacturer_choices]
-
-
 class DeviceTypeFilterForm(forms.Form, BootstrapMixin):
-    manufacturer = forms.MultipleChoiceField(required=False, choices=devicetype_manufacturer_choices,
-                                             widget=forms.SelectMultiple(attrs={'size': 8}))
+    manufacturer = FilterChoiceField(queryset=Manufacturer.objects.annotate(filter_count=Count('device_types')),
+                                     to_field_name='slug')
 
 
 #
@@ -627,49 +590,18 @@ class DeviceBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
     serial = forms.CharField(max_length=50, required=False, label='Serial Number')
 
 
-def device_site_choices():
-    site_choices = Site.objects.annotate(device_count=Count('racks__devices'))
-    return [(s.slug, u'{} ({})'.format(s.name, s.device_count)) for s in site_choices]
-
-
-def device_rack_group_choices():
-    group_choices = RackGroup.objects.select_related('site').annotate(device_count=Count('racks__devices'))
-    return [(g.pk, u'{} ({})'.format(g, g.device_count)) for g in group_choices]
-
-
-def device_role_choices():
-    role_choices = DeviceRole.objects.annotate(device_count=Count('devices'))
-    return [(r.slug, u'{} ({})'.format(r.name, r.device_count)) for r in role_choices]
-
-
-def device_tenant_choices():
-    tenant_choices = Tenant.objects.annotate(device_count=Count('devices'))
-    return [(t.slug, u'{} ({})'.format(t.name, t.device_count)) for t in tenant_choices]
-
-
-def device_type_choices():
-    type_choices = DeviceType.objects.select_related('manufacturer').annotate(device_count=Count('instances'))
-    return [(t.pk, u'{} ({})'.format(t, t.device_count)) for t in type_choices]
-
-
-def device_platform_choices():
-    platform_choices = Platform.objects.annotate(device_count=Count('devices'))
-    return [(p.slug, u'{} ({})'.format(p.name, p.device_count)) for p in platform_choices]
-
-
 class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm):
     model = Device
-    site = forms.MultipleChoiceField(required=False, choices=device_site_choices,
-                                     widget=forms.SelectMultiple(attrs={'size': 8}))
-    rack_group_id = forms.MultipleChoiceField(required=False, choices=device_rack_group_choices, label='Rack Group',
-                                              widget=forms.SelectMultiple(attrs={'size': 8}))
-    role = forms.MultipleChoiceField(required=False, choices=device_role_choices,
-                                     widget=forms.SelectMultiple(attrs={'size': 8}))
-    tenant = forms.MultipleChoiceField(required=False, choices=device_tenant_choices,
-                                       widget=forms.SelectMultiple(attrs={'size': 8}))
-    device_type_id = forms.MultipleChoiceField(required=False, choices=device_type_choices, label='Type',
-                                               widget=forms.SelectMultiple(attrs={'size': 8}))
-    platform = forms.MultipleChoiceField(required=False, choices=device_platform_choices)
+    site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('racks__devices')), to_field_name='slug')
+    rack_group_id = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('racks__devices')),
+                                      label='Rack Group')
+    role = FilterChoiceField(queryset=DeviceRole.objects.annotate(filter_count=Count('devices')), to_field_name='slug')
+    tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('devices')), to_field_name='slug',
+                               null_option=(0, 'None'))
+    device_type_id = FilterChoiceField(queryset=DeviceType.objects.select_related('manufacturer')
+                                       .annotate(filter_count=Count('instances')), label='Type')
+    platform = FilterChoiceField(queryset=Platform.objects.annotate(filter_count=Count('devices')),
+                                 to_field_name='slug', null_option=(0, 'None'))
     status = forms.NullBooleanField(required=False, widget=forms.Select(choices=FORM_STATUS_CHOICES))
 
 

+ 1 - 1
netbox/dcim/views.py

@@ -1399,7 +1399,7 @@ class InterfaceBulkAddView(PermissionRequiredMixin, BulkEditView):
     template_name = 'dcim/interface_add_multi.html'
     default_redirect_url = 'dcim:device_list'
 
-    def update_objects(self, pk_list, form):
+    def update_objects(self, pk_list, form, fields):
 
         selected_devices = Device.objects.filter(pk__in=pk_list)
         interfaces = []

+ 15 - 2
netbox/extras/filters.py

@@ -2,7 +2,7 @@ import django_filters
 
 from django.contrib.contenttypes.models import ContentType
 
-from .models import CustomField
+from .models import CF_TYPE_SELECT, CustomField
 
 
 class CustomFieldFilter(django_filters.Filter):
@@ -10,9 +10,22 @@ class CustomFieldFilter(django_filters.Filter):
     Filter objects by the presence of a CustomFieldValue. The filter's name is used as the CustomField name.
     """
 
+    def __init__(self, cf_type, *args, **kwargs):
+        self.cf_type = cf_type
+        super(CustomFieldFilter, self).__init__(*args, **kwargs)
+
     def filter(self, queryset, value):
+        # Skip filter on empty value
         if not value.strip():
             return queryset
+        # Treat 0 as None for Select fields
+        try:
+            if self.cf_type == CF_TYPE_SELECT and int(value) == 0:
+                return queryset.exclude(
+                    custom_field_values__field__name=self.name,
+                )
+        except ValueError:
+            pass
         return queryset.filter(
             custom_field_values__field__name=self.name,
             custom_field_values__serialized_value=value,
@@ -30,4 +43,4 @@ class CustomFieldFilterSet(django_filters.FilterSet):
         obj_type = ContentType.objects.get_for_model(self._meta.model)
         custom_fields = CustomField.objects.filter(obj_type=obj_type, is_filterable=True)
         for cf in custom_fields:
-            self.filters['cf_{}'.format(cf.name)] = CustomFieldFilter(name=cf.name)
+            self.filters['cf_{}'.format(cf.name)] = CustomFieldFilter(name=cf.name, cf_type=cf.type)

+ 5 - 7
netbox/extras/forms.py

@@ -47,14 +47,12 @@ def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=F
 
         # Select
         elif cf.type == CF_TYPE_SELECT:
-            if bulk_edit:
-                choices = [(cfc.pk, cfc) for cfc in cf.choices.all()]
-                if not cf.required:
-                    choices = [(0, 'None')] + choices
+            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)
-            else:
-                field = forms.ModelChoiceField(queryset=cf.choices.all(), required=cf.required)
+            field = forms.TypedChoiceField(choices=choices, coerce=int, required=cf.required)
 
         # URL
         elif cf.type == CF_TYPE_URL:

+ 12 - 1
netbox/extras/models.py

@@ -67,7 +67,18 @@ ACTION_CHOICES = (
 
 class CustomFieldModel(object):
 
-    def custom_fields(self):
+    def cf(self):
+        """
+        Name-based CustomFieldValue accessor for use in templates
+        """
+        if not hasattr(self, 'custom_fields'):
+            return dict()
+        return {field.name: value for field, value in self.custom_fields.items()}
+
+    def get_custom_fields(self):
+        """
+        Return a dictionary of custom fields for a single object in the form {<field>: value}.
+        """
 
         # Find all custom fields applicable to this type of object
         content_type = ContentType.objects.get_for_model(self)

+ 47 - 76
netbox/ipam/filters.py

@@ -7,6 +7,7 @@ from django.db.models import Q
 from dcim.models import Site, Device, Interface
 from extras.filters import CustomFieldFilterSet
 from tenancy.models import Tenant
+from utilities.filters import NullableModelMultipleChoiceFilter
 
 from .models import RIR, Aggregate, VRF, Prefix, IPAddress, VLAN, VLANGroup, Role
 
@@ -21,12 +22,12 @@ class VRFFilter(CustomFieldFilterSet, django_filters.FilterSet):
         lookup_type='icontains',
         label='Name',
     )
-    tenant_id = django_filters.ModelMultipleChoiceFilter(
+    tenant_id = NullableModelMultipleChoiceFilter(
         name='tenant',
         queryset=Tenant.objects.all(),
         label='Tenant (ID)',
     )
-    tenant = django_filters.ModelMultipleChoiceFilter(
+    tenant = NullableModelMultipleChoiceFilter(
         name='tenant',
         queryset=Tenant.objects.all(),
         to_field_name='slug',
@@ -85,29 +86,34 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet):
         action='search_by_parent',
         label='Parent prefix',
     )
-    vrf = django_filters.MethodFilter(
-        action='_vrf',
+    vrf_id = NullableModelMultipleChoiceFilter(
+        name='vrf_id',
+        queryset=VRF.objects.all(),
         label='VRF',
     )
-    # Duplicate of `vrf` for backward-compatibility
-    vrf_id = django_filters.MethodFilter(
-        action='_vrf',
-        label='VRF',
+    vrf = NullableModelMultipleChoiceFilter(
+        name='vrf',
+        queryset=VRF.objects.all(),
+        to_field_name='rd',
+        label='VRF (RD)',
     )
-    tenant_id = django_filters.MethodFilter(
-        action='_tenant_id',
+    tenant_id = NullableModelMultipleChoiceFilter(
+        name='tenant',
+        queryset=Tenant.objects.all(),
         label='Tenant (ID)',
     )
-    tenant = django_filters.MethodFilter(
-        action='_tenant',
-        label='Tenant',
+    tenant = NullableModelMultipleChoiceFilter(
+        name='tenant',
+        queryset=Tenant.objects.all(),
+        to_field_name='slug',
+        label='Tenant (slug)',
     )
-    site_id = django_filters.ModelMultipleChoiceFilter(
+    site_id = NullableModelMultipleChoiceFilter(
         name='site',
         queryset=Site.objects.all(),
         label='Site (ID)',
     )
-    site = django_filters.ModelMultipleChoiceFilter(
+    site = NullableModelMultipleChoiceFilter(
         name='site',
         queryset=Site.objects.all(),
         to_field_name='slug',
@@ -122,12 +128,12 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet):
         name='vlan__vid',
         label='VLAN number (1-4095)',
     )
-    role_id = django_filters.ModelMultipleChoiceFilter(
+    role_id = NullableModelMultipleChoiceFilter(
         name='role',
         queryset=Role.objects.all(),
         label='Role (ID)',
     )
-    role = django_filters.ModelMultipleChoiceFilter(
+    role = NullableModelMultipleChoiceFilter(
         name='role',
         queryset=Role.objects.all(),
         to_field_name='slug',
@@ -136,7 +142,7 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet):
 
     class Meta:
         model = Prefix
-        fields = ['family', 'site_id', 'site', 'vrf', 'vrf_id', 'vlan_id', 'vlan_vid', 'status', 'role_id', 'role']
+        fields = ['family', 'site_id', 'site', 'vlan_id', 'vlan_vid', 'status', 'role_id', 'role']
 
     def search(self, queryset, value):
         qs_filter = Q(description__icontains=value)
@@ -157,17 +163,6 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet):
         except AddrFormatError:
             return queryset.none()
 
-    def _vrf(self, queryset, value):
-        if str(value) == '':
-            return queryset
-        try:
-            vrf_id = int(value)
-        except ValueError:
-            return queryset.none()
-        if vrf_id == 0:
-            return queryset.filter(vrf__isnull=True)
-        return queryset.filter(vrf__pk=value)
-
     def _tenant(self, queryset, value):
         if str(value) == '':
             return queryset
@@ -196,22 +191,27 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet):
         action='search_by_parent',
         label='Parent prefix',
     )
-    vrf = django_filters.MethodFilter(
-        action='_vrf',
+    vrf_id = NullableModelMultipleChoiceFilter(
+        name='vrf_id',
+        queryset=VRF.objects.all(),
         label='VRF',
     )
-    # Duplicate of `vrf` for backward-compatibility
-    vrf_id = django_filters.MethodFilter(
-        action='_vrf',
-        label='VRF',
+    vrf = NullableModelMultipleChoiceFilter(
+        name='vrf',
+        queryset=VRF.objects.all(),
+        to_field_name='rd',
+        label='VRF (RD)',
     )
-    tenant_id = django_filters.MethodFilter(
-        action='_tenant_id',
+    tenant_id = NullableModelMultipleChoiceFilter(
+        name='tenant',
+        queryset=Tenant.objects.all(),
         label='Tenant (ID)',
     )
-    tenant = django_filters.MethodFilter(
-        action='_tenant',
-        label='Tenant',
+    tenant = NullableModelMultipleChoiceFilter(
+        name='tenant',
+        queryset=Tenant.objects.all(),
+        to_field_name='slug',
+        label='Tenant (slug)',
     )
     device_id = django_filters.ModelMultipleChoiceFilter(
         name='interface__device',
@@ -232,7 +232,7 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet):
 
     class Meta:
         model = IPAddress
-        fields = ['q', 'family', 'vrf_id', 'vrf', 'device_id', 'device', 'interface_id']
+        fields = ['q', 'family', 'device_id', 'device', 'interface_id']
 
     def search(self, queryset, value):
         qs_filter = Q(description__icontains=value)
@@ -253,35 +253,6 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet):
         except AddrFormatError:
             return queryset.none()
 
-    def _vrf(self, queryset, value):
-        if str(value) == '':
-            return queryset
-        try:
-            vrf_id = int(value)
-        except ValueError:
-            return queryset.none()
-        if vrf_id == 0:
-            return queryset.filter(vrf__isnull=True)
-        return queryset.filter(vrf__pk=value)
-
-    def _tenant(self, queryset, value):
-        if str(value) == '':
-            return queryset
-        return queryset.filter(
-            Q(tenant__slug=value) |
-            Q(tenant__isnull=True, vrf__tenant__slug=value)
-        )
-
-    def _tenant_id(self, queryset, value):
-        try:
-            value = int(value)
-        except ValueError:
-            return queryset.none()
-        return queryset.filter(
-            Q(tenant__pk=value) |
-            Q(tenant__isnull=True, vrf__tenant__pk=value)
-        )
-
 
 class VLANGroupFilter(django_filters.FilterSet):
     site_id = django_filters.ModelMultipleChoiceFilter(
@@ -317,12 +288,12 @@ class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet):
         to_field_name='slug',
         label='Site (slug)',
     )
-    group_id = django_filters.ModelMultipleChoiceFilter(
+    group_id = NullableModelMultipleChoiceFilter(
         name='group',
         queryset=VLANGroup.objects.all(),
         label='Group (ID)',
     )
-    group = django_filters.ModelMultipleChoiceFilter(
+    group = NullableModelMultipleChoiceFilter(
         name='group',
         queryset=VLANGroup.objects.all(),
         to_field_name='slug',
@@ -337,23 +308,23 @@ class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet):
         name='vid',
         label='VLAN number (1-4095)',
     )
-    tenant_id = django_filters.ModelMultipleChoiceFilter(
+    tenant_id = NullableModelMultipleChoiceFilter(
         name='tenant',
         queryset=Tenant.objects.all(),
         label='Tenant (ID)',
     )
-    tenant = django_filters.ModelMultipleChoiceFilter(
+    tenant = NullableModelMultipleChoiceFilter(
         name='tenant',
         queryset=Tenant.objects.all(),
         to_field_name='slug',
         label='Tenant (slug)',
     )
-    role_id = django_filters.ModelMultipleChoiceFilter(
+    role_id = NullableModelMultipleChoiceFilter(
         name='role',
         queryset=Role.objects.all(),
         label='Role (ID)',
     )
-    role = django_filters.ModelMultipleChoiceFilter(
+    role = NullableModelMultipleChoiceFilter(
         name='role',
         queryset=Role.objects.all(),
         to_field_name='slug',

+ 29 - 90
netbox/ipam/forms.py

@@ -5,7 +5,9 @@ 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 BootstrapMixin, APISelect, Livesearch, CSVDataField, BulkImportForm, SlugField
+from utilities.forms import (
+    APISelect, BootstrapMixin, CSVDataField, BulkImportForm, FilterChoiceField, Livesearch, SlugField,
+)
 
 from .models import (
     Aggregate, IPAddress, Prefix, PREFIX_STATUS_CHOICES, RIR, Role, VLAN, VLANGroup, VLAN_STATUS_CHOICES, VRF,
@@ -69,15 +71,10 @@ class VRFBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
     description = forms.CharField(max_length=100, required=False)
 
 
-def vrf_tenant_choices():
-    tenant_choices = Tenant.objects.annotate(vrf_count=Count('vrfs'))
-    return [(t.slug, u'{} ({})'.format(t.name, t.vrf_count)) for t in tenant_choices]
-
-
 class VRFFilterForm(BootstrapMixin, CustomFieldFilterForm):
     model = VRF
-    tenant = forms.MultipleChoiceField(required=False, choices=vrf_tenant_choices,
-                                       widget=forms.SelectMultiple(attrs={'size': 8}))
+    tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('vrfs')), to_field_name='slug',
+                               null_option=(0, None))
 
 
 #
@@ -128,16 +125,11 @@ class AggregateBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
     description = forms.CharField(max_length=100, required=False)
 
 
-def aggregate_rir_choices():
-    rir_choices = RIR.objects.annotate(aggregate_count=Count('aggregates'))
-    return [(r.slug, u'{} ({})'.format(r.name, r.aggregate_count)) for r in rir_choices]
-
-
 class AggregateFilterForm(BootstrapMixin, CustomFieldFilterForm):
     model = Aggregate
     family = forms.ChoiceField(required=False, choices=IP_FAMILY_CHOICES, label='Address Family')
-    rir = forms.MultipleChoiceField(required=False, choices=aggregate_rir_choices, label='RIR',
-                                    widget=forms.SelectMultiple(attrs={'size': 8}))
+    rir = FilterChoiceField(queryset=RIR.objects.annotate(filter_count=Count('aggregates')), to_field_name='slug',
+                            label='RIR')
 
 
 #
@@ -268,21 +260,6 @@ class PrefixBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
     description = forms.CharField(max_length=100, required=False)
 
 
-def prefix_vrf_choices():
-    vrf_choices = VRF.objects.annotate(prefix_count=Count('prefixes'))
-    return [(v.pk, u'{} ({})'.format(v.name, v.prefix_count)) for v in vrf_choices]
-
-
-def tenant_choices():
-    tenant_choices = Tenant.objects.all()
-    return [(t.slug, t.name) for t in tenant_choices]
-
-
-def prefix_site_choices():
-    site_choices = Site.objects.annotate(prefix_count=Count('prefixes'))
-    return [(s.slug, u'{} ({})'.format(s.name, s.prefix_count)) for s in site_choices]
-
-
 def prefix_status_choices():
     status_counts = {}
     for status in Prefix.objects.values('status').annotate(count=Count('status')).order_by('status'):
@@ -290,27 +267,21 @@ def prefix_status_choices():
     return [(s[0], u'{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in PREFIX_STATUS_CHOICES]
 
 
-def prefix_role_choices():
-    role_choices = Role.objects.annotate(prefix_count=Count('prefixes'))
-    return [(r.slug, u'{} ({})'.format(r.name, r.prefix_count)) for r in role_choices]
-
-
 class PrefixFilterForm(BootstrapMixin, CustomFieldFilterForm):
     model = Prefix
     parent = forms.CharField(required=False, label='Search Within', widget=forms.TextInput(attrs={
         'placeholder': 'Network',
     }))
     family = forms.ChoiceField(required=False, choices=IP_FAMILY_CHOICES, label='Address Family')
-    vrf = forms.MultipleChoiceField(required=False, choices=prefix_vrf_choices, label='VRF',
-                                    widget=forms.SelectMultiple(attrs={'size': 6}))
-    tenant = forms.MultipleChoiceField(required=False, choices=tenant_choices, label='Tenant',
-                                       widget=forms.SelectMultiple(attrs={'size': 6}))
-    status = forms.MultipleChoiceField(required=False, choices=prefix_status_choices,
-                                       widget=forms.SelectMultiple(attrs={'size': 6}))
-    site = forms.MultipleChoiceField(required=False, choices=prefix_site_choices,
-                                     widget=forms.SelectMultiple(attrs={'size': 6}))
-    role = forms.MultipleChoiceField(required=False, choices=prefix_role_choices,
-                                     widget=forms.SelectMultiple(attrs={'size': 6}))
+    vrf = FilterChoiceField(queryset=VRF.objects.annotate(filter_count=Count('prefixes')), to_field_name='rd',
+                            label='VRF', null_option=(0, 'Global'))
+    tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('prefixes')), to_field_name='slug',
+                               null_option=(0, 'None'))
+    status = forms.MultipleChoiceField(choices=prefix_status_choices, required=False)
+    site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('prefixes')), to_field_name='slug',
+                             null_option=(0, 'None'))
+    role = FilterChoiceField(queryset=Role.objects.annotate(filter_count=Count('prefixes')), to_field_name='slug',
+                             null_option=(0, 'None'))
     expand = forms.BooleanField(required=False, label='Expand prefix hierarchy')
 
 
@@ -441,21 +412,16 @@ class IPAddressBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
     description = forms.CharField(max_length=100, required=False)
 
 
-def ipaddress_vrf_choices():
-    vrf_choices = VRF.objects.annotate(ipaddress_count=Count('ip_addresses'))
-    return [(v.pk, u'{} ({})'.format(v.name, v.ipaddress_count)) for v in vrf_choices]
-
-
 class IPAddressFilterForm(BootstrapMixin, CustomFieldFilterForm):
     model = IPAddress
     parent = forms.CharField(required=False, label='Search Within', widget=forms.TextInput(attrs={
         'placeholder': 'Prefix',
     }))
     family = forms.ChoiceField(required=False, choices=IP_FAMILY_CHOICES, label='Address Family')
-    vrf = forms.MultipleChoiceField(required=False, choices=ipaddress_vrf_choices, label='VRF',
-                                    widget=forms.SelectMultiple(attrs={'size': 6}))
-    tenant = forms.MultipleChoiceField(required=False, choices=tenant_choices, label='Tenant',
-                                       widget=forms.SelectMultiple(attrs={'size': 6}))
+    vrf = FilterChoiceField(queryset=VRF.objects.annotate(filter_count=Count('ip_addresses')), to_field_name='rd',
+                            label='VRF', null_option=(0, 'Global'))
+    tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('ip_addresses')),
+                               to_field_name='slug', null_option=(0, 'None'))
 
 
 #
@@ -470,14 +436,8 @@ class VLANGroupForm(forms.ModelForm, BootstrapMixin):
         fields = ['site', 'name', 'slug']
 
 
-def vlangroup_site_choices():
-    site_choices = Site.objects.annotate(vlangroup_count=Count('vlan_groups'))
-    return [(s.slug, u'{} ({})'.format(s.name, s.vlangroup_count)) for s in site_choices]
-
-
 class VLANGroupFilterForm(forms.Form, BootstrapMixin):
-    site = forms.MultipleChoiceField(required=False, choices=vlangroup_site_choices,
-                                     widget=forms.SelectMultiple(attrs={'size': 8}))
+    site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('vlan_groups')), to_field_name='slug')
 
 
 #
@@ -555,21 +515,6 @@ class VLANBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
     description = forms.CharField(max_length=100, required=False)
 
 
-def vlan_site_choices():
-    site_choices = Site.objects.annotate(vlan_count=Count('vlans'))
-    return [(s.slug, u'{} ({})'.format(s.name, s.vlan_count)) for s in site_choices]
-
-
-def vlan_group_choices():
-    group_choices = VLANGroup.objects.select_related('site').annotate(vlan_count=Count('vlans'))
-    return [(g.pk, u'{} ({})'.format(g, g.vlan_count)) for g in group_choices]
-
-
-def vlan_tenant_choices():
-    tenant_choices = Tenant.objects.annotate(vrf_count=Count('vlans'))
-    return [(t.slug, u'{} ({})'.format(t.name, t.vrf_count)) for t in tenant_choices]
-
-
 def vlan_status_choices():
     status_counts = {}
     for status in VLAN.objects.values('status').annotate(count=Count('status')).order_by('status'):
@@ -577,19 +522,13 @@ def vlan_status_choices():
     return [(s[0], u'{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in VLAN_STATUS_CHOICES]
 
 
-def vlan_role_choices():
-    role_choices = Role.objects.annotate(vlan_count=Count('vlans'))
-    return [(r.slug, u'{} ({})'.format(r.name, r.vlan_count)) for r in role_choices]
-
-
 class VLANFilterForm(BootstrapMixin, CustomFieldFilterForm):
     model = VLAN
-    site = forms.MultipleChoiceField(required=False, choices=vlan_site_choices,
-                                     widget=forms.SelectMultiple(attrs={'size': 8}))
-    group_id = forms.MultipleChoiceField(required=False, choices=vlan_group_choices, label='VLAN Group',
-                                         widget=forms.SelectMultiple(attrs={'size': 8}))
-    tenant = forms.MultipleChoiceField(required=False, choices=vlan_tenant_choices,
-                                       widget=forms.SelectMultiple(attrs={'size': 8}))
-    status = forms.MultipleChoiceField(required=False, choices=vlan_status_choices)
-    role = forms.MultipleChoiceField(required=False, choices=vlan_role_choices,
-                                     widget=forms.SelectMultiple(attrs={'size': 8}))
+    site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('vlans')), to_field_name='slug')
+    group_id = FilterChoiceField(queryset=VLANGroup.objects.annotate(filter_count=Count('vlans')), label='VLAN group',
+                                 null_option=(0, 'None'))
+    tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('vlans')), to_field_name='slug',
+                               null_option=(0, 'None'))
+    status = forms.MultipleChoiceField(choices=vlan_status_choices, required=False)
+    role = FilterChoiceField(queryset=Role.objects.annotate(filter_count=Count('vlans')), to_field_name='slug',
+                             null_option=(0, 'None'))

+ 19 - 0
netbox/ipam/migrations/0008_prefix_change_order.py

@@ -0,0 +1,19 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.10 on 2016-09-15 16:08
+from __future__ import unicode_literals
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('ipam', '0007_prefix_ipaddress_add_tenant'),
+    ]
+
+    operations = [
+        migrations.AlterModelOptions(
+            name='prefix',
+            options={'ordering': ['vrf', 'family', 'prefix'], 'verbose_name_plural': 'prefixes'},
+        ),
+    ]

+ 3 - 2
netbox/ipam/models.py

@@ -12,6 +12,7 @@ from dcim.models import Interface
 from extras.models import CustomFieldModel, CustomFieldValue
 from tenancy.models import Tenant
 from utilities.models import CreatedUpdatedModel
+from utilities.sql import NullsFirstQuerySet
 
 from .fields import IPNetworkField, IPAddressField
 
@@ -192,7 +193,7 @@ class Role(models.Model):
         return self.vlans.count()
 
 
-class PrefixQuerySet(models.QuerySet):
+class PrefixQuerySet(NullsFirstQuerySet):
 
     def annotate_depth(self, limit=None):
         """
@@ -249,7 +250,7 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel):
     objects = PrefixQuerySet.as_manager()
 
     class Meta:
-        ordering = ['family', 'prefix']
+        ordering = ['vrf', 'family', 'prefix']
         verbose_name_plural = 'prefixes'
 
     def __unicode__(self):

+ 2 - 2
netbox/netbox/settings.py

@@ -12,7 +12,7 @@ except ImportError:
                                "the documentation.")
 
 
-VERSION = '1.6.0'
+VERSION = '1.6.1'
 
 # Import local configuration
 for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']:
@@ -71,7 +71,7 @@ if LDAP_CONFIGURED:
         logger.setLevel(logging.DEBUG)
     except ImportError:
         raise ImproperlyConfigured("LDAP authentication has been configured, but django-auth-ldap is not installed. "
-                                   "You can remove netbox/ldap.py to disable LDAP.")
+                                   "You can remove netbox/ldap_config.py to disable LDAP.")
 
 BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
 

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

@@ -25,7 +25,7 @@ $(document).ready(function() {
     });
     if (slug_field) {
         var slug_source = $('#id_' + slug_field.attr('slug-source'));
-        slug_source.keyup(function() {
+        slug_source.on('keyup change', function() {
             if (slug_field && !slug_field.attr('_changed')) {
                 slug_field.val(slugify($(this).val(), 50));
             }

+ 2 - 7
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, SlugField
+from utilities.forms import BootstrapMixin, BulkImportForm, CSVDataField, FilterChoiceField, SlugField
 
 from .models import Secret, SecretRole, UserKey
 
@@ -95,13 +95,8 @@ class SecretBulkEditForm(forms.Form, BootstrapMixin):
     name = forms.CharField(max_length=100, required=False)
 
 
-def secret_role_choices():
-    role_choices = SecretRole.objects.annotate(secret_count=Count('secrets'))
-    return [(r.slug, u'{} ({})'.format(r.name, r.secret_count)) for r in role_choices]
-
-
 class SecretFilterForm(forms.Form, BootstrapMixin):
-    role = forms.MultipleChoiceField(required=False, choices=secret_role_choices)
+    role = FilterChoiceField(queryset=SecretRole.objects.annotate(filter_count=Count('secrets')), to_field_name='slug')
 
 
 #

+ 3 - 0
netbox/templates/circuits/circuit.html

@@ -104,6 +104,9 @@
                 </tr>
             </table>
         </div>
+        {% with circuit.get_custom_fields as custom_fields %}
+            {% include 'inc/custom_fields_panel.html' %}
+        {% endwith %}
         {% include 'inc/created_updated.html' with obj=circuit %}
 	</div>
 	<div class="col-md-6">

+ 1 - 1
netbox/templates/circuits/provider.html

@@ -105,7 +105,7 @@
                 </tr>
             </table>
         </div>
-        {% with provider.custom_fields as custom_fields %}
+        {% with provider.get_custom_fields as custom_fields %}
             {% include 'inc/custom_fields_panel.html' %}
         {% endwith %}
         <div class="panel panel-default">

+ 1 - 1
netbox/templates/dcim/device.html

@@ -144,7 +144,7 @@
                 </tr>
             </table>
         </div>
-        {% with device.custom_fields as custom_fields %}
+        {% with device.get_custom_fields as custom_fields %}
             {% include 'inc/custom_fields_panel.html' %}
         {% endwith %}
         {% if request.user.is_authenticated %}

+ 1 - 1
netbox/templates/dcim/rack.html

@@ -132,7 +132,7 @@
                 </tr>
             </table>
         </div>
-        {% with rack.custom_fields as custom_fields %}
+        {% with rack.get_custom_fields as custom_fields %}
             {% include 'inc/custom_fields_panel.html' %}
         {% endwith %}
         <div class="panel panel-default">

+ 1 - 1
netbox/templates/dcim/site.html

@@ -111,7 +111,7 @@
                 </tr>
             </table>
         </div>
-        {% with site.custom_fields as custom_fields %}
+        {% with site.get_custom_fields as custom_fields %}
             {% include 'inc/custom_fields_panel.html' %}
         {% endwith %}
         <div class="panel panel-default">

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

@@ -82,7 +82,7 @@
         {% include 'inc/created_updated.html' with obj=aggregate %}
     </div>
     <div class="col-md-6">
-        {% with aggregate.custom_fields as custom_fields %}
+        {% with aggregate.get_custom_fields as custom_fields %}
             {% include 'inc/custom_fields_panel.html' %}
         {% endwith %}
     </div>

+ 3 - 0
netbox/templates/ipam/ipaddress.html

@@ -121,6 +121,9 @@
                 </tr>
             </table>
         </div>
+        {% with ipaddress.get_custom_fields as custom_fields %}
+            {% include 'inc/custom_fields_panel.html' %}
+        {% endwith %}
         {% include 'inc/created_updated.html' with obj=ipaddress %}
 	</div>
 	<div class="col-md-6">

+ 3 - 0
netbox/templates/ipam/prefix.html

@@ -101,6 +101,9 @@
                 </tr>
             </table>
         </div>
+        {% with prefix.get_custom_fields as custom_fields %}
+            {% include 'inc/custom_fields_panel.html' %}
+        {% endwith %}
         {% include 'inc/created_updated.html' with obj=prefix %}
         <br />
 	</div>

+ 9 - 0
netbox/templates/ipam/prefix_list.html

@@ -6,6 +6,15 @@
 
 {% block content %}
 <div class="pull-right">
+    <a href="{% url 'ipam:prefix_list' %}{% querystring_toggle request expand='on' %}" class="btn btn-default">
+        {% if 'expand' in request.GET %}
+            <span class="fa fa-chevron-right" aria-hidden="true"></span>
+            Collapse all
+        {% else %}
+            <span class="fa fa-chevron-down" aria-hidden="true"></span>
+            Expand all
+        {% endif %}
+    </a>
     {% if perms.ipam.add_prefix %}
 		<a href="{% url 'ipam:prefix_add' %}" class="btn btn-primary">
 			<span class="fa fa-plus" aria-hidden="true"></span>

+ 3 - 0
netbox/templates/ipam/vlan.html

@@ -110,6 +110,9 @@
                 </tr>
 		    </table>
         </div>
+        {% with vlan.get_custom_fields as custom_fields %}
+            {% include 'inc/custom_fields_panel.html' %}
+        {% endwith %}
         {% include 'inc/created_updated.html' with obj=vlan %}
 	</div>
 	<div class="col-md-6">

+ 3 - 0
netbox/templates/ipam/vrf.html

@@ -82,6 +82,9 @@
                 </tr>
 		    </table>
         </div>
+        {% with vrf.get_custom_fields as custom_fields %}
+            {% include 'inc/custom_fields_panel.html' %}
+        {% endwith %}
         {% include 'inc/created_updated.html' with obj=vrf %}
 	</div>
 	<div class="col-md-6">

+ 1 - 1
netbox/templates/tenancy/tenant.html

@@ -65,7 +65,7 @@
                 </tr>
             </table>
         </div>
-        {% with tenant.custom_fields as custom_fields %}
+        {% with tenant.get_custom_fields as custom_fields %}
             {% include 'inc/custom_fields_panel.html' %}
         {% endwith %}
         <div class="panel panel-default">

+ 3 - 2
netbox/tenancy/filters.py

@@ -3,6 +3,7 @@ import django_filters
 from django.db.models import Q
 
 from extras.filters import CustomFieldFilterSet
+from utilities.filters import NullableModelMultipleChoiceFilter
 from .models import Tenant, TenantGroup
 
 
@@ -11,12 +12,12 @@ class TenantFilter(CustomFieldFilterSet, django_filters.FilterSet):
         action='search',
         label='Search',
     )
-    group_id = django_filters.ModelMultipleChoiceFilter(
+    group_id = NullableModelMultipleChoiceFilter(
         name='group',
         queryset=TenantGroup.objects.all(),
         label='Group (ID)',
     )
-    group = django_filters.ModelMultipleChoiceFilter(
+    group = NullableModelMultipleChoiceFilter(
         name='group',
         queryset=TenantGroup.objects.all(),
         to_field_name='slug',

+ 3 - 8
netbox/tenancy/forms.py

@@ -2,7 +2,7 @@ from django import forms
 from django.db.models import Count
 
 from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
-from utilities.forms import BootstrapMixin, BulkImportForm, CommentField, CSVDataField, SlugField
+from utilities.forms import BootstrapMixin, BulkImportForm, CommentField, CSVDataField, FilterChoiceField, SlugField
 
 from .models import Tenant, TenantGroup
 
@@ -74,12 +74,7 @@ class TenantBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
     group = forms.TypedChoiceField(choices=bulkedit_tenantgroup_choices, coerce=int, required=False, label='Group')
 
 
-def tenant_group_choices():
-    group_choices = TenantGroup.objects.annotate(tenant_count=Count('tenants'))
-    return [(g.slug, u'{} ({})'.format(g.name, g.tenant_count)) for g in group_choices]
-
-
 class TenantFilterForm(BootstrapMixin, CustomFieldFilterForm):
     model = Tenant
-    group = forms.MultipleChoiceField(required=False, choices=tenant_group_choices,
-                                      widget=forms.SelectMultiple(attrs={'size': 8}))
+    group = FilterChoiceField(queryset=TenantGroup.objects.annotate(filter_count=Count('tenants')),
+                              to_field_name='slug', null_option=(0, 'None'))

+ 93 - 0
netbox/utilities/filters.py

@@ -0,0 +1,93 @@
+import django_filters
+import itertools
+
+from django import forms
+from django.db.models import Q
+from django.utils.encoding import force_text
+
+
+class NullableModelMultipleChoiceField(forms.ModelMultipleChoiceField):
+    """
+    This field operates like a normal ModelMultipleChoiceField except that it allows for one additional choice which is
+    used to represent a value of Null. This is accomplished by creating a new iterator which first yields the null
+    choice before entering the queryset iterator, and by ignoring the null choice during cleaning. The effect is similar
+    to defining a MultipleChoiceField with:
+
+        choices = [(0, 'None')] + [(x.id, x) for x in Foo.objects.all()]
+
+    However, the above approach forces immediate evaluation of the queryset, which can cause issues when calculating
+    database migrations.
+    """
+    iterator = forms.models.ModelChoiceIterator
+
+    def __init__(self, null_value=0, null_label='None', *args, **kwargs):
+        self.null_value = null_value
+        self.null_label = null_label
+        super(NullableModelMultipleChoiceField, self).__init__(*args, **kwargs)
+
+    def _get_choices(self):
+        if hasattr(self, '_choices'):
+            return self._choices
+        # Prepend the null choice to the queryset iterator
+        return itertools.chain(
+            [(self.null_value, self.null_label)],
+            self.iterator(self),
+        )
+    choices = property(_get_choices, forms.ChoiceField._set_choices)
+
+    def clean(self, value):
+        # Strip all instances of the null value before cleaning
+        if value is not None:
+            stripped_value = [x for x in value if x != force_text(self.null_value)]
+        else:
+            stripped_value = value
+        super(NullableModelMultipleChoiceField, self).clean(stripped_value)
+        return value
+
+
+class NullableModelMultipleChoiceFilter(django_filters.ModelMultipleChoiceFilter):
+    """
+    This class extends ModelMultipleChoiceFilter to accept an additional value which implies "is null". The default
+    queryset filter argument is:
+
+        .filter(fieldname=value)
+
+    When filtering by the value representing "is null" ('0' by default) the argument is modified to:
+
+        .filter(fieldname__isnull=True)
+    """
+    field_class = NullableModelMultipleChoiceField
+
+    def __init__(self, *args, **kwargs):
+        self.null_value = kwargs.get('null_value', 0)
+        super(NullableModelMultipleChoiceFilter, self).__init__(*args, **kwargs)
+
+    def filter(self, qs, value):
+        value = value or ()  # Make sure we have an iterable
+
+        if self.is_noop(qs, value):
+            return qs
+
+        # Even though not a noop, no point filtering if empty
+        if not value:
+            return qs
+
+        q = Q()
+        for v in set(value):
+            # Filtering by "is null"
+            if v == force_text(self.null_value):
+                arg = {'{}__isnull'.format(self.name): True}
+            # Filtering by a related field (e.g. slug)
+            elif self.field.to_field_name is not None:
+                arg = {'{}__{}'.format(self.name, self.field.to_field_name): v}
+            # Filtering by primary key (default)
+            else:
+                arg = {self.name: v}
+            if self.conjoined:
+                qs = self.get_method(qs)(**arg)
+            else:
+                q |= Q(**arg)
+        if self.distinct:
+            return self.get_method(qs)(q).distinct()
+
+        return self.get_method(qs)(q)

+ 32 - 1
netbox/utilities/forms.py

@@ -1,4 +1,5 @@
 import csv
+import itertools
 import re
 
 from django import forms
@@ -142,10 +143,14 @@ class CSVDataField(forms.CharField):
         if not self.help_text:
             self.help_text = 'Enter one line per record in CSV format.'
 
+    def utf_8_encoder(self, unicode_csv_data):
+        for line in unicode_csv_data:
+            yield line.encode('utf-8')
+
     def to_python(self, value):
         # Return a list of dictionaries, each representing an individual record
         records = []
-        reader = csv.reader(value.splitlines())
+        reader = csv.reader(self.utf_8_encoder(value.splitlines()))
         for i, row in enumerate(reader, start=1):
             if row:
                 if len(row) < len(self.columns):
@@ -222,6 +227,32 @@ class SlugField(forms.SlugField):
         self.widget.attrs['slug-source'] = slug_source
 
 
+class FilterChoiceField(forms.ModelMultipleChoiceField):
+    iterator = forms.models.ModelChoiceIterator
+
+    def __init__(self, null_option=None, *args, **kwargs):
+        self.null_option = null_option
+        if 'required' not in kwargs:
+            kwargs['required'] = False
+        if 'widget' not in kwargs:
+            kwargs['widget'] = forms.SelectMultiple(attrs={'size': 6})
+        super(FilterChoiceField, self).__init__(*args, **kwargs)
+
+    def label_from_instance(self, obj):
+        if hasattr(obj, 'filter_count'):
+            return u'{} ({})'.format(obj, obj.filter_count)
+        return force_text(obj)
+
+    def _get_choices(self):
+        if hasattr(self, '_choices'):
+            return self._choices
+        if self.null_option is not None:
+            return itertools.chain([self.null_option], self.iterator(self))
+        return self.iterator(self)
+
+    choices = property(_get_choices, forms.ChoiceField._set_choices)
+
+
 #
 # Forms
 #

+ 32 - 0
netbox/utilities/sql.py

@@ -0,0 +1,32 @@
+from django.db import connections, models
+from django.db.models.sql.compiler import SQLCompiler
+
+
+class NullsFirstSQLCompiler(SQLCompiler):
+
+    def get_order_by(self):
+        result = super(NullsFirstSQLCompiler, self).get_order_by()
+        if result:
+            return [(expr, (sql + ' NULLS FIRST', params, is_ref)) for (expr, (sql, params, is_ref)) in result]
+        return result
+
+
+class NullsFirstQuery(models.sql.query.Query):
+
+    def get_compiler(self, using=None, connection=None):
+        if using is None and connection is None:
+            raise ValueError("Need either using or connection")
+        if using:
+            connection = connections[using]
+        return NullsFirstSQLCompiler(self, connection, using)
+
+
+class NullsFirstQuerySet(models.QuerySet):
+    """
+    Override PostgreSQL's default behavior of ordering NULLs last. This is needed e.g. to order Prefixes in the global
+    table before those assigned to a VRF.
+    """
+
+    def __init__(self, model=None, query=None, using=None, hints=None):
+        super(NullsFirstQuerySet, self).__init__(model, query, using, hints)
+        self.query = query or NullsFirstQuery(self.model)

+ 25 - 5
netbox/utilities/views.py

@@ -1,3 +1,4 @@
+from collections import OrderedDict
 from django_tables2 import RequestConfig
 
 from django.contrib import messages
@@ -10,18 +11,30 @@ from django.forms import ModelMultipleChoiceField, MultipleHiddenInput, TypedCho
 from django.http import HttpResponse, HttpResponseRedirect
 from django.shortcuts import get_object_or_404, redirect, render
 from django.template import TemplateSyntaxError
-from django.utils.decorators import method_decorator
 from django.utils.http import is_safe_url
 from django.views.generic import View
 
 from extras.forms import CustomFieldForm
-from extras.models import CustomFieldValue, ExportTemplate, UserAction
+from extras.models import CustomField, CustomFieldValue, ExportTemplate, UserAction
 
 from .error_handlers import handle_protectederror
 from .forms import ConfirmationForm
 from .paginator import EnhancedPaginator
 
 
+class annotate_custom_fields:
+
+    def __init__(self, queryset, custom_fields):
+        self.queryset = queryset
+        self.custom_fields = custom_fields
+
+    def __iter__(self):
+        for obj in self.queryset:
+            values_dict = {cfv.field_id: cfv.value for cfv in obj.custom_field_values.all()}
+            obj.custom_fields = OrderedDict([(field, values_dict.get(field.pk)) for field in self.custom_fields])
+            yield obj
+
+
 class ObjectListView(View):
     queryset = None
     filter = None
@@ -39,19 +52,26 @@ class ObjectListView(View):
         if self.filter:
             self.queryset = self.filter(request.GET, self.queryset).qs
 
+        # If this type of object has one or more custom fields, prefetch any relevant custom field values
+        custom_fields = CustomField.objects.filter(obj_type=ContentType.objects.get_for_model(model))\
+            .prefetch_related('choices')
+        if custom_fields:
+            self.queryset = self.queryset.prefetch_related('custom_field_values')
+
         # Check for export template rendering
         if request.GET.get('export'):
             et = get_object_or_404(ExportTemplate, content_type=object_ct, name=request.GET.get('export'))
+            queryset = annotate_custom_fields(self.queryset, custom_fields) if custom_fields else self.queryset
             try:
-                response = et.to_response(context_dict={'queryset': self.queryset.all()},
-                                          filename='netbox_{}'.format(self.queryset.model._meta.verbose_name_plural))
+                response = et.to_response(context_dict={'queryset': queryset},
+                                          filename='netbox_{}'.format(model._meta.verbose_name_plural))
                 return response
             except TemplateSyntaxError:
                 messages.error(request, "There was an error rendering the selected export template ({})."
                                .format(et.name))
         # Fall back to built-in CSV export
         elif 'export' in request.GET and hasattr(model, 'to_csv'):
-            output = '\n'.join([obj.to_csv() for obj in self.queryset.all()])
+            output = '\n'.join([obj.to_csv() for obj in self.queryset])
             response = HttpResponse(
                 output,
                 content_type='text/csv'

+ 0 - 0
scripts/docker-build.sh