Browse Source

Merge branch 'develop' into develop-2.3

Jeremy Stretch 7 years ago
parent
commit
0714a40509

+ 5 - 3
docs/installation/ldap.md

@@ -24,7 +24,7 @@ sudo pip install django-auth-ldap
 
 # Configuration
 
-Create a file in the same directory as `configuration.py` (typically `netbox/netbox/`) named `ldap_config.py`. Define all of the parameters required below in `ldap_config.py`.
+Create a file in the same directory as `configuration.py` (typically `netbox/netbox/`) named `ldap_config.py`. Define all of the parameters required below in `ldap_config.py`. Complete documentation of all `django-auth-ldap` configuration options is included in the project's [official documentation](http://django-auth-ldap.readthedocs.io/).
 
 ## General Server Configuration
 
@@ -52,6 +52,8 @@ AUTH_LDAP_BIND_PASSWORD = "demo"
 LDAP_IGNORE_CERT_ERRORS = True
 ```
 
+STARTTLS can be configured by setting `AUTH_LDAP_START_TLS = True` and using the `ldap://` URI scheme.
+
 ## User Authentication
 
 !!! info
@@ -78,8 +80,8 @@ AUTH_LDAP_USER_ATTR_MAP = {
 ```
 
 # User Groups for Permissions
-!!! Info
-    When using Microsoft Active Directory, Support for nested Groups can be activated by using `GroupOfNamesType()` instead of `NestedGroupOfNamesType()` for `AUTH_LDAP_GROUP_TYPE`.
+!!! info
+    When using Microsoft Active Directory, support for nested groups can be activated by using `NestedGroupOfNamesType()` instead of `GroupOfNamesType()` for `AUTH_LDAP_GROUP_TYPE`.
 
 ```python
 from django_auth_ldap.config import LDAPSearch, GroupOfNamesType

+ 1 - 1
netbox/circuits/forms.py

@@ -174,7 +174,7 @@ class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm):
     tenant = FilterChoiceField(
         queryset=Tenant.objects.annotate(filter_count=Count('circuits')),
         to_field_name='slug',
-        null_option=(0, 'None')
+        null_label='-- None --'
     )
     site = FilterChoiceField(
         queryset=Site.objects.annotate(filter_count=Count('circuit_terminations')),

+ 2 - 0
netbox/dcim/api/serializers.py

@@ -776,6 +776,8 @@ class InventoryItemSerializer(serializers.ModelSerializer):
 
 
 class WritableInventoryItemSerializer(ValidatedModelSerializer):
+    # Provide a default value to satisfy UniqueTogetherValidator
+    parent = serializers.PrimaryKeyRelatedField(queryset=InventoryItem.objects.all(), allow_null=True, default=None)
 
     class Meta:
         model = InventoryItem

+ 3 - 3
netbox/dcim/filters.py

@@ -163,7 +163,7 @@ class RackFilter(CustomFieldFilterSet, django_filters.FilterSet):
 
     class Meta:
         model = Rack
-        fields = ['serial', 'type', 'width', 'u_height', 'desc_units']
+        fields = ['name', 'serial', 'type', 'width', 'u_height', 'desc_units']
 
     def search(self, queryset, name, value):
         if not value.strip():
@@ -340,7 +340,7 @@ class DeviceRoleFilter(django_filters.FilterSet):
 
     class Meta:
         model = DeviceRole
-        fields = ['name', 'slug', 'color']
+        fields = ['name', 'slug', 'color', 'vm_role']
 
 
 class PlatformFilter(django_filters.FilterSet):
@@ -476,7 +476,7 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
 
     class Meta:
         model = Device
-        fields = ['serial']
+        fields = ['serial', 'position']
 
     def search(self, queryset, name, value):
         if not value.strip():

+ 9 - 9
netbox/dcim/forms.py

@@ -175,7 +175,7 @@ class SiteFilterForm(BootstrapMixin, CustomFieldFilterForm):
     tenant = FilterChoiceField(
         queryset=Tenant.objects.annotate(filter_count=Count('sites')),
         to_field_name='slug',
-        null_option=(0, 'None')
+        null_label='-- None --'
     )
 
 
@@ -371,17 +371,17 @@ class RackFilterForm(BootstrapMixin, CustomFieldFilterForm):
     group_id = FilterChoiceField(
         queryset=RackGroup.objects.select_related('site').annotate(filter_count=Count('racks')),
         label='Rack group',
-        null_option=(0, 'None')
+        null_label='-- None --'
     )
     tenant = FilterChoiceField(
         queryset=Tenant.objects.annotate(filter_count=Count('racks')),
         to_field_name='slug',
-        null_option=(0, 'None')
+        null_label='-- None --'
     )
     role = FilterChoiceField(
         queryset=RackRole.objects.annotate(filter_count=Count('racks')),
         to_field_name='slug',
-        null_option=(0, 'None')
+        null_label='-- None --'
     )
 
 
@@ -423,12 +423,12 @@ class RackReservationFilterForm(BootstrapMixin, forms.Form):
     group_id = FilterChoiceField(
         queryset=RackGroup.objects.select_related('site').annotate(filter_count=Count('racks__reservations')),
         label='Rack group',
-        null_option=(0, 'None')
+        null_label='-- None --'
     )
     tenant = FilterChoiceField(
         queryset=Tenant.objects.annotate(filter_count=Count('rackreservations')),
         to_field_name='slug',
-        null_option=(0, 'None')
+        null_label='-- None --'
     )
 
 
@@ -1053,7 +1053,7 @@ class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm):
     rack_id = FilterChoiceField(
         queryset=Rack.objects.annotate(filter_count=Count('devices')),
         label='Rack',
-        null_option=(0, 'None'),
+        null_label='-- None --',
     )
     role = FilterChoiceField(
         queryset=DeviceRole.objects.annotate(filter_count=Count('devices')),
@@ -1062,7 +1062,7 @@ class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm):
     tenant = FilterChoiceField(
         queryset=Tenant.objects.annotate(filter_count=Count('devices')),
         to_field_name='slug',
-        null_option=(0, 'None'),
+        null_label='-- None --',
     )
     manufacturer_id = FilterChoiceField(queryset=Manufacturer.objects.all(), label='Manufacturer')
     device_type_id = FilterChoiceField(
@@ -1074,7 +1074,7 @@ class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm):
     platform = FilterChoiceField(
         queryset=Platform.objects.annotate(filter_count=Count('devices')),
         to_field_name='slug',
-        null_option=(0, 'None'),
+        null_label='-- None --',
     )
     status = forms.MultipleChoiceField(choices=device_status_choices, required=False)
     mac_address = forms.CharField(required=False, label='MAC address')

+ 2 - 1
netbox/dcim/tables.py

@@ -389,6 +389,7 @@ class PlatformTable(BaseTable):
     pk = ToggleColumn()
     name = tables.LinkColumn(verbose_name='Name')
     device_count = tables.Column(verbose_name='Devices')
+    vm_count = tables.Column(verbose_name='VMs')
     slug = tables.Column(verbose_name='Slug')
     actions = tables.TemplateColumn(
         template_code=PLATFORM_ACTIONS,
@@ -398,7 +399,7 @@ class PlatformTable(BaseTable):
 
     class Meta(BaseTable.Meta):
         model = Platform
-        fields = ('pk', 'name', 'manufacturer', 'device_count', 'slug', 'napalm_driver', 'actions')
+        fields = ('pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'slug', 'napalm_driver', 'actions')
 
 
 #

+ 4 - 1
netbox/dcim/views.py

@@ -802,7 +802,10 @@ class DeviceRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 #
 
 class PlatformListView(ObjectListView):
-    queryset = Platform.objects.annotate(device_count=Count('devices'))
+    queryset = Platform.objects.annotate(
+        device_count=Count('devices', distinct=True),
+        vm_count=Count('virtual_machines', distinct=True)
+    )
     table = tables.PlatformTable
     template_name = 'dcim/platform_list.html'
 

+ 16 - 13
netbox/ipam/forms.py

@@ -78,8 +78,11 @@ class VRFBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
 class VRFFilterForm(BootstrapMixin, CustomFieldFilterForm):
     model = VRF
     q = forms.CharField(required=False, label='Search')
-    tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('vrfs')), to_field_name='slug',
-                               null_option=(0, None))
+    tenant = FilterChoiceField(
+        queryset=Tenant.objects.annotate(filter_count=Count('vrfs')),
+        to_field_name='slug',
+        null_label='-- None --'
+    )
 
 
 #
@@ -368,23 +371,23 @@ class PrefixFilterForm(BootstrapMixin, CustomFieldFilterForm):
         queryset=VRF.objects.annotate(filter_count=Count('prefixes')),
         to_field_name='rd',
         label='VRF',
-        null_option=(0, 'Global')
+        null_label='-- Global --'
     )
     tenant = FilterChoiceField(
         queryset=Tenant.objects.annotate(filter_count=Count('prefixes')),
         to_field_name='slug',
-        null_option=(0, 'None')
+        null_label='-- 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')
+        null_label='-- None --'
     )
     role = FilterChoiceField(
         queryset=Role.objects.annotate(filter_count=Count('prefixes')),
         to_field_name='slug',
-        null_option=(0, 'None')
+        null_label='-- None --'
     )
     expand = forms.BooleanField(required=False, label='Expand prefix hierarchy')
 
@@ -719,12 +722,12 @@ class IPAddressFilterForm(BootstrapMixin, CustomFieldFilterForm):
         queryset=VRF.objects.annotate(filter_count=Count('ip_addresses')),
         to_field_name='rd',
         label='VRF',
-        null_option=(0, 'Global')
+        null_label='-- Global --'
     )
     tenant = FilterChoiceField(
         queryset=Tenant.objects.annotate(filter_count=Count('ip_addresses')),
         to_field_name='slug',
-        null_option=(0, 'None')
+        null_label='-- None --'
     )
     status = forms.MultipleChoiceField(choices=ipaddress_status_choices, required=False)
     role = forms.MultipleChoiceField(choices=ipaddress_role_choices, required=False)
@@ -766,7 +769,7 @@ class VLANGroupFilterForm(BootstrapMixin, forms.Form):
     site = FilterChoiceField(
         queryset=Site.objects.annotate(filter_count=Count('vlan_groups')),
         to_field_name='slug',
-        null_option=(0, 'Global')
+        null_label='-- Global --'
     )
 
 
@@ -896,23 +899,23 @@ class VLANFilterForm(BootstrapMixin, CustomFieldFilterForm):
     site = FilterChoiceField(
         queryset=Site.objects.annotate(filter_count=Count('vlans')),
         to_field_name='slug',
-        null_option=(0, 'Global')
+        null_label='-- Global --'
     )
     group_id = FilterChoiceField(
         queryset=VLANGroup.objects.annotate(filter_count=Count('vlans')),
         label='VLAN group',
-        null_option=(0, 'None')
+        null_label='-- None --'
     )
     tenant = FilterChoiceField(
         queryset=Tenant.objects.annotate(filter_count=Count('vlans')),
         to_field_name='slug',
-        null_option=(0, 'None')
+        null_label='-- 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')
+        null_label='-- None --'
     )
 
 

+ 22 - 15
netbox/ipam/models.py

@@ -298,10 +298,20 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel):
 
     def get_child_ips(self):
         """
-        Return all IPAddresses within this Prefix.
+        Return all IPAddresses within this Prefix and VRF.
         """
         return IPAddress.objects.filter(address__net_host_contained=str(self.prefix), vrf=self.vrf)
 
+    def get_available_prefixes(self):
+        """
+        Return all available Prefixes within this prefix as an IPSet.
+        """
+        prefix = netaddr.IPSet(self.prefix)
+        child_prefixes = netaddr.IPSet([child.prefix for child in self.get_child_prefixes()])
+        available_prefixes = prefix - child_prefixes
+
+        return available_prefixes
+
     def get_available_ips(self):
         """
         Return all available IPs within this prefix as an IPSet.
@@ -319,15 +329,23 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel):
 
         return available_ips
 
+    def get_first_available_prefix(self):
+        """
+        Return the first available child prefix within the prefix (or None).
+        """
+        available_prefixes = self.get_available_prefixes()
+        if not available_prefixes:
+            return None
+        return available_prefixes.iter_cidrs()[0]
+
     def get_first_available_ip(self):
         """
         Return the first available IP within the prefix (or None).
         """
         available_ips = self.get_available_ips()
-        if available_ips:
-            return '{}/{}'.format(next(available_ips.__iter__()), self.prefix.prefixlen)
-        else:
+        if not available_ips:
             return None
+        return '{}/{}'.format(next(available_ips.__iter__()), self.prefix.prefixlen)
 
     def get_utilization(self):
         """
@@ -345,17 +363,6 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel):
                 prefix_size -= 2
             return int(float(child_count) / prefix_size * 100)
 
-    @property
-    def new_subnet(self):
-        if self.family == 4:
-            if self.prefix.prefixlen <= 30:
-                return netaddr.IPNetwork('{}/{}'.format(self.prefix.network, self.prefix.prefixlen + 1))
-            return None
-        if self.family == 6:
-            if self.prefix.prefixlen <= 126:
-                return netaddr.IPNetwork('{}/{}'.format(self.prefix.network, self.prefix.prefixlen + 1))
-            return None
-
 
 class IPAddressManager(models.Manager):
 

+ 1 - 7
netbox/ipam/tables.py

@@ -48,13 +48,7 @@ PREFIX_LINK = """
 {% else %}
     <span class="text-nowrap" style="padding-left: {{ record.depth }}9px">
 {% endif %}
-    <a href="{% if record.pk %}{% url 'ipam:prefix' pk=record.pk %}{% else %}{% url 'ipam:prefix_add' %}?prefix={{ record }}{% if parent.vrf %}&vrf={{ parent.vrf.pk }}{% endif %}{% if parent.site %}&site={{ parent.site.pk }}{% endif %}{% endif %}">{{ record.prefix }}</a>
-</span>
-"""
-
-PREFIX_LINK_BRIEF = """
-<span style="padding-left: {{ record.depth }}0px">
-    <a href="{% if record.pk %}{% url 'ipam:prefix' pk=record.pk %}{% else %}{% url 'ipam:prefix_add' %}?prefix={{ record }}{% if parent.vrf %}&vrf={{ parent.vrf.pk }}{% endif %}{% if parent.site %}&site={{ parent.site.pk }}{% endif %}{% endif %}">{{ record.prefix }}</a>
+    <a href="{% if record.pk %}{% url 'ipam:prefix' pk=record.pk %}{% else %}{% url 'ipam:prefix_add' %}?prefix={{ record }}{% if parent.vrf %}&vrf={{ parent.vrf.pk }}{% endif %}{% if parent.site %}&site={{ parent.site.pk }}{% endif %}{% if parent.tenant %}&tenant_group={{ parent.tenant.group.pk }}&tenant={{ parent.tenant.pk }}{% endif %}{% endif %}">{{ record.prefix }}</a>
 </span>
 """
 

+ 1 - 0
netbox/ipam/urls.py

@@ -51,6 +51,7 @@ urlpatterns = [
     url(r'^prefixes/(?P<pk>\d+)/$', views.PrefixView.as_view(), name='prefix'),
     url(r'^prefixes/(?P<pk>\d+)/edit/$', views.PrefixEditView.as_view(), name='prefix_edit'),
     url(r'^prefixes/(?P<pk>\d+)/delete/$', views.PrefixDeleteView.as_view(), name='prefix_delete'),
+    url(r'^prefixes/(?P<pk>\d+)/prefixes/$', views.PrefixPrefixesView.as_view(), name='prefix_prefixes'),
     url(r'^prefixes/(?P<pk>\d+)/ip-addresses/$', views.PrefixIPAddressesView.as_view(), name='prefix_ipaddresses'),
 
     # IP addresses

+ 23 - 10
netbox/ipam/views.py

@@ -476,6 +476,20 @@ class PrefixView(View):
         duplicate_prefix_table = tables.PrefixTable(list(duplicate_prefixes), orderable=False)
         duplicate_prefix_table.exclude = ('vrf',)
 
+        return render(request, 'ipam/prefix.html', {
+            'prefix': prefix,
+            'aggregate': aggregate,
+            'parent_prefix_table': parent_prefix_table,
+            'duplicate_prefix_table': duplicate_prefix_table,
+        })
+
+
+class PrefixPrefixesView(View):
+
+    def get(self, request, pk):
+
+        prefix = get_object_or_404(Prefix.objects.all(), pk=pk)
+
         # Child prefixes table
         child_prefixes = Prefix.objects.filter(
             vrf=prefix.vrf, prefix__net_contained=str(prefix.prefix)
@@ -484,15 +498,16 @@ class PrefixView(View):
         ).annotate_depth(limit=0)
         if child_prefixes:
             child_prefixes = add_available_prefixes(prefix.prefix, child_prefixes)
-        child_prefix_table = tables.PrefixDetailTable(child_prefixes)
+
+        prefix_table = tables.PrefixDetailTable(child_prefixes)
         if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'):
-            child_prefix_table.columns.show('pk')
+            prefix_table.columns.show('pk')
 
         paginate = {
             'klass': EnhancedPaginator,
             'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
         }
-        RequestConfig(request, paginate).configure(child_prefix_table)
+        RequestConfig(request, paginate).configure(prefix_table)
 
         # Compile permissions list for rendering the object table
         permissions = {
@@ -501,15 +516,12 @@ class PrefixView(View):
             'delete': request.user.has_perm('ipam.delete_prefix'),
         }
 
-        return render(request, 'ipam/prefix.html', {
+        return render(request, 'ipam/prefix_prefixes.html', {
             'prefix': prefix,
-            'aggregate': aggregate,
-            'parent_prefix_table': parent_prefix_table,
-            'child_prefix_table': child_prefix_table,
-            'duplicate_prefix_table': duplicate_prefix_table,
-            'bulk_querystring': 'vrf_id={}&within={}'.format(prefix.vrf or '0', prefix.prefix),
+            'first_available_prefix': prefix.get_first_available_prefix(),
+            'prefix_table': prefix_table,
             'permissions': permissions,
-            'return_url': prefix.get_absolute_url(),
+            'bulk_querystring': 'vrf_id={}&within={}'.format(prefix.vrf.pk if prefix.vrf else '0', prefix.prefix),
         })
 
 
@@ -544,6 +556,7 @@ class PrefixIPAddressesView(View):
 
         return render(request, 'ipam/prefix_ipaddresses.html', {
             'prefix': prefix,
+            'first_available_ip': prefix.get_first_available_ip(),
             'ip_table': ip_table,
             'permissions': permissions,
             'bulk_querystring': 'vrf_id={}&parent={}'.format(prefix.vrf.pk if prefix.vrf else '0', prefix.prefix),

+ 3 - 0
netbox/netbox/api.py

@@ -20,6 +20,9 @@ class FormlessBrowsableAPIRenderer(BrowsableAPIRenderer):
     def show_form_for_method(self, *args, **kwargs):
         return False
 
+    def get_filter_form(self, data, view, request):
+        return None
+
 
 #
 # Authentication

+ 4 - 1
netbox/templates/dcim/device_lldp_neighbors.html

@@ -58,9 +58,10 @@ $(document).ready(function() {
                 // Glean configured hostnames/interfaces from the DOM
                 var configured_device = row.children('td.configured_device').attr('data');
                 var configured_interface = row.children('td.configured_interface').attr('data');
+                var configured_interface_short = null;
                 if (configured_interface) {
                     // Match long-form IOS names against short ones (e.g. Gi0/1 == GigabitEthernet0/1).
-                    configured_interface = configured_interface.replace(/^([A-Z][a-z])[^0-9]*([0-9\/]+)$/, "$1$2");
+                    configured_interface_short = configured_interface.replace(/^([A-Z][a-z])[^0-9]*([0-9\/]+)$/, "$1$2");
                 }
 
                 // Clean up hostnames/interfaces learned via LLDP
@@ -76,6 +77,8 @@ $(document).ready(function() {
                     row.addClass('info');
                 } else if (configured_device == lldp_device && configured_interface == lldp_interface) {
                     row.addClass('success');
+                } else if (configured_device == lldp_device && configured_interface_short == lldp_interface) {
+                    row.addClass('success');
                 } else {
                     row.addClass('danger');
                 }

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

@@ -13,7 +13,7 @@
             Import device types
         </a>
     {% endif %}
-    {% include 'inc/export_button.html' with obj_type='devicetypes' %}
+    {% include 'inc/export_button.html' with obj_type='device types' %}
 </div>
 <h1>{% block title %}Device Types{% endblock %}</h1>
 <div class="row">

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

@@ -13,7 +13,7 @@
             Import rack groups
         </a>
     {% endif %}
-    {% include 'inc/export_button.html' with obj_type='rackgroups' %}
+    {% include 'inc/export_button.html' with obj_type='rack groups' %}
 </div>
 <h1>{% block title %}Rack Groups{% endblock %}</h1>
 <div class="row">

+ 8 - 2
netbox/templates/ipam/inc/prefix_header.html

@@ -22,8 +22,13 @@
     </div>
 </div>
 <div class="pull-right">
-    {% if perms.ipam.add_ipaddress %}
-		<a href="{% url 'ipam:ipaddress_add' %}?address={{ prefix.get_first_available_ip }}&vrf={{ prefix.vrf.pk }}&tenant_group={{ prefix.tenant.group.pk }}&tenant={{ prefix.tenant.pk }}" class="btn btn-success">
+    {% if perms.ipam.add_prefix and active_tab == 'prefixes' and first_available_prefix %}
+        <a href="{% url 'ipam:prefix_add' %}?prefix={{ first_available_prefix }}&vrf={{ prefix.vrf.pk }}&site={{ prefix.site.pk }}&tenant_group={{ prefix.tenant.group.pk }}&tenant={{ prefix.tenant.pk }}" class="btn btn-success">
+            <i class="fa fa-plus" aria-hidden="true"></i> Add Child Prefix
+        </a>
+    {% endif %}
+    {% if perms.ipam.add_ipaddress and active_tab == 'ip-addresses' and first_available_ip %}
+		<a href="{% url 'ipam:ipaddress_add' %}?address={{ first_available_ip }}&vrf={{ prefix.vrf.pk }}&tenant_group={{ prefix.tenant.group.pk }}&tenant={{ prefix.tenant.pk }}" class="btn btn-success">
 			<span class="fa fa-plus" aria-hidden="true"></span>
 			Add an IP Address
 		</a>
@@ -45,5 +50,6 @@
 {% include 'inc/created_updated.html' with obj=prefix %}
 <ul class="nav nav-tabs" style="margin-bottom: 20px">
     <li role="presentation"{% if active_tab == 'prefix' %} class="active"{% endif %}><a href="{% url 'ipam:prefix' pk=prefix.pk %}">Prefix</a></li>
+    <li role="presentation"{% if active_tab == 'prefixes' %} class="active"{% endif %}><a href="{% url 'ipam:prefix_prefixes' pk=prefix.pk %}">Child Prefixes <span class="badge">{{ prefix.get_child_prefixes.count }}</span></a></li>
     <li role="presentation"{% if active_tab == 'ip-addresses' %} class="active"{% endif %}><a href="{% url 'ipam:prefix_ipaddresses' pk=prefix.pk %}">IP Addresses <span class="badge">{{ prefix.get_child_ips.count }}</span></a></li>
 </ul>

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

@@ -139,15 +139,4 @@
         {% include 'panel_table.html' with table=parent_prefix_table heading='Parent Prefixes' %}
 	</div>
 </div>
-<div class="row">
-	<div class="col-md-12">
-        {% if child_prefix_table.rows %}
-            {% include 'utilities/obj_table.html' with table=child_prefix_table table_template='panel_table.html' heading='Child Prefixes' parent=prefix bulk_edit_url='ipam:prefix_bulk_edit' bulk_delete_url='ipam:prefix_bulk_delete' %}
-        {% elif prefix.new_subnet %}
-            <a href="{% url 'ipam:prefix_add' %}?prefix={{ prefix.new_subnet }}{% if prefix.vrf %}&vrf={{ prefix.vrf.pk }}{% endif %}{% if prefix.site %}&site={{ prefix.site.pk }}{% endif %}" class="btn btn-success">
-                <i class="fa fa-plus" aria-hidden="true"></i> Add Child Prefix
-            </a>
-        {% endif %}
-    </div>
-</div>
 {% endblock %}

+ 5 - 5
netbox/templates/ipam/prefix_ipaddresses.html

@@ -3,10 +3,10 @@
 {% block title %}{{ prefix }} - IP Addresses{% endblock %}
 
 {% block content %}
-{% include 'ipam/inc/prefix_header.html' with active_tab='ip-addresses' %}
-<div class="row">
-	<div class="col-md-12">
-        {% include 'utilities/obj_table.html' with table=ip_table table_template='panel_table.html' heading='IP Addresses' bulk_edit_url='ipam:ipaddress_bulk_edit' bulk_delete_url='ipam:ipaddress_bulk_delete' %}
+    {% include 'ipam/inc/prefix_header.html' with active_tab='ip-addresses' %}
+    <div class="row">
+        <div class="col-md-12">
+            {% include 'utilities/obj_table.html' with table=ip_table table_template='panel_table.html' heading='IP Addresses' bulk_edit_url='ipam:ipaddress_bulk_edit' bulk_delete_url='ipam:ipaddress_bulk_delete' %}
+        </div>
     </div>
-</div>
 {% endblock %}

+ 12 - 0
netbox/templates/ipam/prefix_prefixes.html

@@ -0,0 +1,12 @@
+{% extends '_base.html' %}
+
+{% block title %}{{ prefix }} - Prefixes{% endblock %}
+
+{% block content %}
+    {% include 'ipam/inc/prefix_header.html' with active_tab='prefixes' %}
+    <div class="row">
+        <div class="col-md-12">
+            {% include 'utilities/obj_table.html' with table=prefix_table table_template='panel_table.html' heading='Child Prefixes' bulk_edit_url='ipam:prefix_bulk_edit' bulk_delete_url='ipam:prefix_bulk_delete' parent=prefix %}
+        </div>
+    </div>
+{% endblock %}

+ 7 - 5
netbox/templates/search.html

@@ -13,12 +13,14 @@
                     {% for obj_type in results %}
                         <h3 id="{{ obj_type.name|lower }}">{{ obj_type.name|bettertitle }}</h3>
                         {% include 'panel_table.html' with table=obj_type.table hide_paginator=True %}
-                        {% if obj_type.table.page.has_next %}
-                            <a href="{{ obj_type.url }}" class="btn btn-primary pull-right">
-                                <span class="fa fa-arrow-right" aria-hidden="true"></span>
+                        <a href="{{ obj_type.url }}" class="btn btn-primary pull-right">
+                            <span class="fa fa-arrow-right" aria-hidden="true"></span>
+                            {% if obj_type.table.page.has_next %}
                                 See all {{ obj_type.table.page.paginator.count }} results
-                            </a>
-                        {% endif %}
+                            {% else %}
+                                Refine search
+                            {% endif %}
+                        </a>
                     <div class="clearfix"></div>
                     {% endfor %}
                 </div>

+ 1 - 1
netbox/tenancy/forms.py

@@ -81,7 +81,7 @@ class TenantFilterForm(BootstrapMixin, CustomFieldFilterForm):
     group = FilterChoiceField(
         queryset=TenantGroup.objects.annotate(filter_count=Count('tenants')),
         to_field_name='slug',
-        null_option=(0, 'None')
+        null_label='-- None --'
     )
 
 

+ 1 - 1
netbox/utilities/filters.py

@@ -42,7 +42,7 @@ class NullableModelMultipleChoiceField(forms.ModelMultipleChoiceField):
     """
     iterator = forms.models.ModelChoiceIterator
 
-    def __init__(self, null_value=0, null_label='None', *args, **kwargs):
+    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)

+ 17 - 12
netbox/utilities/forms.py

@@ -407,11 +407,25 @@ class SlugField(forms.SlugField):
         self.widget.attrs['slug-source'] = slug_source
 
 
+class FilterChoiceIterator(forms.models.ModelChoiceIterator):
+
+    def __iter__(self):
+        # Filter on "empty" choice using FILTERS_NULL_CHOICE_VALUE (instead of an empty string)
+        if self.field.null_label is not None:
+            yield (settings.FILTERS_NULL_CHOICE_VALUE, self.field.null_label)
+        queryset = self.queryset.all()
+        # Can't use iterator() when queryset uses prefetch_related()
+        if not queryset._prefetch_related_lookups:
+            queryset = queryset.iterator()
+        for obj in queryset:
+            yield self.choice(obj)
+
+
 class FilterChoiceFieldMixin(object):
-    iterator = forms.models.ModelChoiceIterator
+    iterator = FilterChoiceIterator
 
-    def __init__(self, null_option=None, *args, **kwargs):
-        self.null_option = null_option
+    def __init__(self, null_label=None, *args, **kwargs):
+        self.null_label = null_label
         if 'required' not in kwargs:
             kwargs['required'] = False
         if 'widget' not in kwargs:
@@ -424,15 +438,6 @@ class FilterChoiceFieldMixin(object):
             return '{} ({})'.format(label, obj.filter_count)
         return label
 
-    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)
-
 
 class FilterChoiceField(FilterChoiceFieldMixin, forms.ModelMultipleChoiceField):
     pass

+ 5 - 1
netbox/utilities/middleware.py

@@ -4,7 +4,7 @@ import sys
 
 from django.conf import settings
 from django.db import ProgrammingError
-from django.http import HttpResponseRedirect
+from django.http import Http404, HttpResponseRedirect
 from django.shortcuts import render
 from django.urls import reverse
 
@@ -61,6 +61,10 @@ class ExceptionHandlingMiddleware(object):
         if settings.DEBUG:
             return
 
+        # Ignore Http404s (defer to Django's built-in 404 handling)
+        if isinstance(exception, Http404):
+            return
+
         # Determine the type of exception
         if isinstance(exception, ProgrammingError):
             template_name = 'exceptions/programming_error.html'

+ 7 - 1
netbox/utilities/views.py

@@ -309,8 +309,14 @@ class BulkCreateView(View):
 
     def get(self, request):
 
+        # Set initial values for visible form fields from query args
+        initial = {}
+        for field in getattr(self.model_form._meta, 'fields', []):
+            if request.GET.get(field):
+                initial[field] = request.GET[field]
+
         form = self.form()
-        model_form = self.model_form()
+        model_form = self.model_form(initial=initial)
 
         return render(request, self.template_name, {
             'obj_type': self.model_form._meta.model._meta.verbose_name,

+ 11 - 0
netbox/virtualization/filters.py

@@ -84,6 +84,17 @@ class VirtualMachineFilter(CustomFieldFilterSet):
         to_field_name='slug',
         label='Cluster group (slug)',
     )
+    cluster_type_id = django_filters.ModelMultipleChoiceFilter(
+        name='cluster__type',
+        queryset=ClusterType.objects.all(),
+        label='Cluster type (ID)',
+    )
+    cluster_type = django_filters.ModelMultipleChoiceFilter(
+        name='cluster__type__slug',
+        queryset=ClusterType.objects.all(),
+        to_field_name='slug',
+        label='Cluster type (slug)',
+    )
     cluster_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Cluster.objects.all(),
         label='Cluster (ID)',

+ 12 - 7
netbox/virtualization/forms.py

@@ -137,13 +137,13 @@ class ClusterFilterForm(BootstrapMixin, CustomFieldFilterForm):
     group = FilterChoiceField(
         queryset=ClusterGroup.objects.annotate(filter_count=Count('clusters')),
         to_field_name='slug',
-        null_option=(0, 'None'),
+        null_label='-- None --',
         required=False,
     )
     site = FilterChoiceField(
         queryset=Site.objects.annotate(filter_count=Count('clusters')),
         to_field_name='slug',
-        null_option=(0, 'None'),
+        null_label='-- None --',
         required=False,
     )
 
@@ -338,7 +338,12 @@ class VirtualMachineFilterForm(BootstrapMixin, CustomFieldFilterForm):
     cluster_group = FilterChoiceField(
         queryset=ClusterGroup.objects.all(),
         to_field_name='slug',
-        null_option=(0, 'None')
+        null_label='-- None --'
+    )
+    cluster_type = FilterChoiceField(
+        queryset=ClusterType.objects.all(),
+        to_field_name='slug',
+        null_label='-- None --'
     )
     cluster_id = FilterChoiceField(
         queryset=Cluster.objects.annotate(filter_count=Count('virtual_machines')),
@@ -347,23 +352,23 @@ class VirtualMachineFilterForm(BootstrapMixin, CustomFieldFilterForm):
     site = FilterChoiceField(
         queryset=Site.objects.annotate(filter_count=Count('clusters__virtual_machines')),
         to_field_name='slug',
-        null_option=(0, 'None')
+        null_label='-- None --'
     )
     role = FilterChoiceField(
         queryset=DeviceRole.objects.filter(vm_role=True).annotate(filter_count=Count('virtual_machines')),
         to_field_name='slug',
-        null_option=(0, 'None')
+        null_label='-- None --'
     )
     status = forms.MultipleChoiceField(choices=vm_status_choices, required=False)
     tenant = FilterChoiceField(
         queryset=Tenant.objects.annotate(filter_count=Count('virtual_machines')),
         to_field_name='slug',
-        null_option=(0, 'None')
+        null_label='-- None --'
     )
     platform = FilterChoiceField(
         queryset=Platform.objects.annotate(filter_count=Count('virtual_machines')),
         to_field_name='slug',
-        null_option=(0, 'None')
+        null_label='-- None --'
     )
 
 

+ 1 - 0
netbox/virtualization/models.py

@@ -139,6 +139,7 @@ class Cluster(CreatedUpdatedModel, CustomFieldModel):
             self.name,
             self.type.name,
             self.group.name if self.group else None,
+            self.site.name if self.site else None,
             self.comments,
         ])