Przeglądaj źródła

Merge pull request #2778 from digitalocean/develop

Release v2.5.3
Jeremy Stretch 7 lat temu
rodzic
commit
e17d79e10f

+ 10 - 5
.github/ISSUE_TEMPLATE/bug_report.md

@@ -17,15 +17,20 @@ about: Report a reproducible bug in the current release of NetBox
 -->
 ### Environment
 * Python version:  <!-- Example: 3.5.4 -->
-* NetBox version:  <!-- Example: 2.3.6 -->
+* NetBox version:  <!-- Example: 2.5.2 -->
 
 <!--
-    Describe in detail the steps that someone else can take to reproduce this
-    bug using the current stable release of NetBox (or the current beta release
-    where applicable).
+    Describe in detail the exact steps that someone else can take to reproduce
+    this bug using the current stable release of NetBox (or the current beta
+    release where applicable). Begin with the creation of any necessary
+    database objects and call out every operation being performed explicitly.
+    If reporting a bug in the REST API, be sure to reconstruct the raw HTTP
+    request(s) being made: Don't rely on a wrapper like pynetbox.
 -->
 ### Steps to Reproduce
-
+1.
+2.
+3.
 
 <!-- What did you expect to happen? -->
 ### Expected Behavior

+ 22 - 0
CHANGELOG.md

@@ -1,3 +1,25 @@
+v2.5.3 (2019-01-11)
+
+## Enhancements
+
+* [#1630](https://github.com/digitalocean/netbox/issues/1630) - Enable bulk editing of prefix/IP mask length
+* [#1870](https://github.com/digitalocean/netbox/issues/1870) - Add per-page toggle to object lists
+* [#1871](https://github.com/digitalocean/netbox/issues/1871) - Enable filtering sites by parent region
+* [#1983](https://github.com/digitalocean/netbox/issues/1983) - Enable regular expressions when bulk renaming device components
+* [#2682](https://github.com/digitalocean/netbox/issues/2682) - Add DAC and AOC cable types
+* [#2693](https://github.com/digitalocean/netbox/issues/2693) - Additional cable colors
+* [#2726](https://github.com/digitalocean/netbox/issues/2726) - Include cables in global search
+
+## Bug Fixes
+
+* [#2742](https://github.com/digitalocean/netbox/issues/2742) - Preserve cluster assignment when editing a device
+* [#2757](https://github.com/digitalocean/netbox/issues/2757) - Always treat first/last IPs within a /31 or /127 as usable
+* [#2762](https://github.com/digitalocean/netbox/issues/2762) - Add missing DCIM field values to API `_choices` endpoint
+* [#2777](https://github.com/digitalocean/netbox/issues/2777) - Fix cable validation to handle duplicate connections on import
+
+
+---
+
 v2.5.2 (2018-12-21)
 
 ## Enhancements

+ 8 - 3
netbox/dcim/api/views.py

@@ -35,13 +35,18 @@ from .exceptions import MissingFilterException
 
 class DCIMFieldChoicesViewSet(FieldChoicesViewSet):
     fields = (
-        (Cable, ['length_unit']),
-        (Device, ['face', 'status']),
+        (Cable, ['length_unit', 'status', 'type']),
         (ConsolePort, ['connection_status']),
-        (Interface, ['connection_status', 'form_factor', 'mode']),
+        (Device, ['face', 'status']),
+        (DeviceType, ['subdevice_role']),
+        (FrontPort, ['type']),
+        (FrontPortTemplate, ['type']),
+        (Interface, ['form_factor', 'mode']),
         (InterfaceTemplate, ['form_factor']),
         (PowerPort, ['connection_status']),
         (Rack, ['outer_unit', 'status', 'type', 'width']),
+        (RearPort, ['type']),
+        (RearPortTemplate, ['type']),
         (Site, ['status']),
     )
 

+ 6 - 0
netbox/dcim/constants.py

@@ -339,11 +339,14 @@ CABLE_TYPE_CAT5E = 1510
 CABLE_TYPE_CAT6 = 1600
 CABLE_TYPE_CAT6A = 1610
 CABLE_TYPE_CAT7 = 1700
+CABLE_TYPE_DAC_ACTIVE = 1800
+CABLE_TYPE_DAC_PASSIVE = 1810
 CABLE_TYPE_MMF_OM1 = 3010
 CABLE_TYPE_MMF_OM2 = 3020
 CABLE_TYPE_MMF_OM3 = 3030
 CABLE_TYPE_MMF_OM4 = 3040
 CABLE_TYPE_SMF = 3500
+CABLE_TYPE_AOC = 3800
 CABLE_TYPE_POWER = 5000
 CABLE_TYPE_CHOICES = (
     (
@@ -354,6 +357,8 @@ CABLE_TYPE_CHOICES = (
             (CABLE_TYPE_CAT6, 'CAT6'),
             (CABLE_TYPE_CAT6A, 'CAT6a'),
             (CABLE_TYPE_CAT7, 'CAT7'),
+            (CABLE_TYPE_DAC_ACTIVE, 'Direct Attach Copper (Active)'),
+            (CABLE_TYPE_DAC_PASSIVE, 'Direct Attach Copper (Passive)'),
         ),
     ),
     (
@@ -363,6 +368,7 @@ CABLE_TYPE_CHOICES = (
             (CABLE_TYPE_MMF_OM3, 'Multimode Fiber (OM3)'),
             (CABLE_TYPE_MMF_OM4, 'Multimode Fiber (OM4)'),
             (CABLE_TYPE_SMF, 'Singlemode Fiber'),
+            (CABLE_TYPE_AOC, 'Active Optical Cabling (AOC)'),
         ),
     ),
     (CABLE_TYPE_POWER, 'Power'),

+ 16 - 6
netbox/dcim/filters.py

@@ -62,14 +62,14 @@ class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet):
         choices=SITE_STATUS_CHOICES,
         null_value=None
     )
-    region_id = django_filters.ModelMultipleChoiceFilter(
-        queryset=Region.objects.all(),
+    region_id = django_filters.NumberFilter(
+        method='filter_region',
+        field_name='pk',
         label='Region (ID)',
     )
-    region = django_filters.ModelMultipleChoiceFilter(
-        field_name='region__slug',
-        queryset=Region.objects.all(),
-        to_field_name='slug',
+    region = django_filters.CharFilter(
+        method='filter_region',
+        field_name='slug',
         label='Region (slug)',
     )
     tenant_id = django_filters.ModelMultipleChoiceFilter(
@@ -108,6 +108,16 @@ class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet):
             pass
         return queryset.filter(qs_filter)
 
+    def filter_region(self, queryset, name, value):
+        try:
+            region = Region.objects.get(**{name: value})
+        except ObjectDoesNotExist:
+            return queryset.none()
+        return queryset.filter(
+            Q(region=region) |
+            Q(region__in=region.get_descendants())
+        )
+
 
 class RackGroupFilter(django_filters.FilterSet):
     q = django_filters.CharFilter(

+ 23 - 4
netbox/dcim/forms.py

@@ -58,6 +58,22 @@ class BulkRenameForm(forms.Form):
     """
     find = forms.CharField()
     replace = forms.CharField()
+    use_regex = forms.BooleanField(
+        required=False,
+        initial=True,
+        label='Use regular expressions'
+    )
+
+    def clean(self):
+
+        # Validate regular expression in "find" field
+        if self.cleaned_data['use_regex']:
+            try:
+                re.compile(self.cleaned_data['find'])
+            except re.error:
+                raise forms.ValidationError({
+                    'find': "Invalid regular expression"
+                })
 
 
 #
@@ -236,9 +252,10 @@ class SiteFilterForm(BootstrapMixin, CustomFieldFilterForm):
         required=False
     )
     region = FilterTreeNodeMultipleChoiceField(
-        queryset=Region.objects.annotate(filter_count=Count('sites')),
+        queryset=Region.objects.all(),
         to_field_name='slug',
         required=False,
+        count_attr='site_count'
     )
     tenant = FilterChoiceField(
         queryset=Tenant.objects.annotate(filter_count=Count('sites')),
@@ -1212,11 +1229,13 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm):
 
         # Initialize helper selectors
         instance = kwargs.get('instance')
+        if 'initial' not in kwargs:
+            kwargs['initial'] = {}
         # Using hasattr() instead of "is not None" to avoid RelatedObjectDoesNotExist on required field
         if instance and hasattr(instance, 'device_type'):
-            initial = kwargs.get('initial', {}).copy()
-            initial['manufacturer'] = instance.device_type.manufacturer
-            kwargs['initial'] = initial
+            kwargs['initial']['manufacturer'] = instance.device_type.manufacturer
+        if instance and instance.cluster is not None:
+            kwargs['initial']['cluster_group'] = instance.cluster.group
 
         super().__init__(*args, **kwargs)
 

+ 53 - 43
netbox/dcim/models.py

@@ -201,6 +201,13 @@ class Region(MPTTModel, ChangeLoggedModel):
             self.parent.name if self.parent else None,
         )
 
+    @property
+    def site_count(self):
+        return Site.objects.filter(
+            Q(region=self) |
+            Q(region__in=self.get_descendants())
+        ).count()
+
 
 #
 # Sites
@@ -2551,52 +2558,55 @@ class Cable(ChangeLoggedModel):
 
     def clean(self):
 
-        # Check that termination types are compatible
-        type_a = self.termination_a_type.model
-        type_b = self.termination_b_type.model
-        if type_b not in COMPATIBLE_TERMINATION_TYPES.get(type_a):
-            raise ValidationError("Incompatible termination types: {} and {}".format(
-                self.termination_a_type, self.termination_b_type
-            ))
+        if self.termination_a and self.termination_b:
 
-        # A termination point cannot be connected to itself
-        if self.termination_a == self.termination_b:
-            raise ValidationError("Cannot connect {} to itself".format(self.termination_a_type))
-
-        # A front port cannot be connected to its corresponding rear port
-        if (
-            type_a in ['frontport', 'rearport'] and
-            type_b in ['frontport', 'rearport'] and
-            (
-                getattr(self.termination_a, 'rear_port', None) == self.termination_b or
-                getattr(self.termination_b, 'rear_port', None) == self.termination_a
-            )
-        ):
-            raise ValidationError("A front port cannot be connected to it corresponding rear port")
+            type_a = self.termination_a_type.model
+            type_b = self.termination_b_type.model
 
-        # Check for an existing Cable connected to either termination object
-        if self.termination_a.cable not in (None, self):
-            raise ValidationError("{} already has a cable attached (#{})".format(
-                self.termination_a, self.termination_a.cable_id
-            ))
-        if self.termination_b.cable not in (None, self):
-            raise ValidationError("{} already has a cable attached (#{})".format(
-                self.termination_b, self.termination_b.cable_id
-            ))
+            # Check that termination types are compatible
+            if type_b not in COMPATIBLE_TERMINATION_TYPES.get(type_a):
+                raise ValidationError("Incompatible termination types: {} and {}".format(
+                    self.termination_a_type, self.termination_b_type
+                ))
 
-        # Virtual interfaces cannot be connected
-        endpoint_a, endpoint_b, _ = self.get_path_endpoints()
-        if (
-            (
-                isinstance(endpoint_a, Interface) and
-                endpoint_a.form_factor == IFACE_FF_VIRTUAL
-            ) or
-            (
-                isinstance(endpoint_b, Interface) and
-                endpoint_b.form_factor == IFACE_FF_VIRTUAL
-            )
-        ):
-            raise ValidationError("Cannot connect to a virtual interface")
+            # A termination point cannot be connected to itself
+            if self.termination_a == self.termination_b:
+                raise ValidationError("Cannot connect {} to itself".format(self.termination_a_type))
+
+            # A front port cannot be connected to its corresponding rear port
+            if (
+                type_a in ['frontport', 'rearport'] and
+                type_b in ['frontport', 'rearport'] and
+                (
+                    getattr(self.termination_a, 'rear_port', None) == self.termination_b or
+                    getattr(self.termination_b, 'rear_port', None) == self.termination_a
+                )
+            ):
+                raise ValidationError("A front port cannot be connected to it corresponding rear port")
+
+            # Check for an existing Cable connected to either termination object
+            if self.termination_a.cable not in (None, self):
+                raise ValidationError("{} already has a cable attached (#{})".format(
+                    self.termination_a, self.termination_a.cable_id
+                ))
+            if self.termination_b.cable not in (None, self):
+                raise ValidationError("{} already has a cable attached (#{})".format(
+                    self.termination_b, self.termination_b.cable_id
+                ))
+
+            # Virtual interfaces cannot be connected
+            endpoint_a, endpoint_b, _ = self.get_path_endpoints()
+            if (
+                (
+                    isinstance(endpoint_a, Interface) and
+                    endpoint_a.form_factor == IFACE_FF_VIRTUAL
+                ) or
+                (
+                    isinstance(endpoint_b, Interface) and
+                    endpoint_b.form_factor == IFACE_FF_VIRTUAL
+                )
+            ):
+                raise ValidationError("Cannot connect to a virtual interface")
 
         # Validate length and length_unit
         if self.length is not None and self.length_unit is None:

+ 13 - 2
netbox/dcim/views.py

@@ -1,3 +1,5 @@
+import re
+
 from django.contrib import messages
 from django.contrib.auth.mixins import PermissionRequiredMixin
 from django.core.paginator import EmptyPage, PageNotAnInteger
@@ -50,7 +52,16 @@ class BulkRenameView(GetReturnURLMixin, View):
 
             if form.is_valid():
                 for obj in selected_objects:
-                    obj.new_name = obj.name.replace(form.cleaned_data['find'], form.cleaned_data['replace'])
+                    find = form.cleaned_data['find']
+                    replace = form.cleaned_data['replace']
+                    if form.cleaned_data['use_regex']:
+                        try:
+                            obj.new_name = re.sub(find, replace, obj.name)
+                        # Catch regex group reference errors
+                        except re.error:
+                            obj.new_name = obj.name
+                    else:
+                        obj.new_name = obj.name.replace(find, replace)
 
                 if '_apply' in request.POST:
                     for obj in selected_objects:
@@ -124,7 +135,7 @@ class BulkDisconnectView(GetReturnURLMixin, View):
 #
 
 class RegionListView(ObjectListView):
-    queryset = Region.objects.annotate(site_count=Count('sites'))
+    queryset = Region.objects.all()
     filter = filters.RegionFilter
     filter_form = forms.RegionFilterForm
     table = tables.RegionTable

+ 10 - 0
netbox/ipam/forms.py

@@ -422,6 +422,11 @@ class PrefixBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF
         required=False,
         label='VRF'
     )
+    prefix_length = forms.IntegerField(
+        min_value=1,
+        max_value=127,
+        required=False
+    )
     tenant = forms.ModelChoiceField(
         queryset=Tenant.objects.all(),
         required=False
@@ -819,6 +824,11 @@ class IPAddressBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEd
         required=False,
         label='VRF'
     )
+    mask_length = forms.IntegerField(
+        min_value=1,
+        max_value=128,
+        required=False
+    )
     tenant = forms.ModelChoiceField(
         queryset=Tenant.objects.all(),
         required=False

+ 35 - 6
netbox/ipam/models.py

@@ -385,6 +385,15 @@ class Prefix(ChangeLoggedModel, CustomFieldModel):
             self.description,
         )
 
+    def _set_prefix_length(self, value):
+        """
+        Expose the IPNetwork object's prefixlen attribute on the parent model so that it can be manipulated directly,
+        e.g. for bulk editing.
+        """
+        if self.prefix is not None:
+            self.prefix.prefixlen = value
+    prefix_length = property(fset=_set_prefix_length)
+
     def get_status_class(self):
         return STATUS_CHOICE_CLASSES[self.status]
 
@@ -429,12 +438,23 @@ class Prefix(ChangeLoggedModel, CustomFieldModel):
         child_ips = netaddr.IPSet([ip.address.ip for ip in self.get_child_ips()])
         available_ips = prefix - child_ips
 
-        # Remove unusable IPs from non-pool prefixes
-        if not self.is_pool:
-            available_ips -= netaddr.IPSet([
-                netaddr.IPAddress(self.prefix.first),
-                netaddr.IPAddress(self.prefix.last),
-            ])
+        # All IP addresses within a pool are considered usable
+        if self.is_pool:
+            return available_ips
+
+        # All IP addresses within a point-to-point prefix (IPv4 /31 or IPv6 /127) are considered usable
+        if (
+            self.family == 4 and self.prefix.prefixlen == 31  # RFC 3021
+        ) or (
+            self.family == 6 and self.prefix.prefixlen == 127  # RFC 6164
+        ):
+            return available_ips
+
+        # Omit first and last IP address from the available set
+        available_ips -= netaddr.IPSet([
+            netaddr.IPAddress(self.prefix.first),
+            netaddr.IPAddress(self.prefix.last),
+        ])
 
         return available_ips
 
@@ -630,6 +650,15 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
             self.description,
         )
 
+    def _set_mask_length(self, value):
+        """
+        Expose the IPNetwork object's prefixlen attribute on the parent model so that it can be manipulated directly,
+        e.g. for bulk editing.
+        """
+        if self.address is not None:
+            self.address.prefixlen = value
+    mask_length = property(fset=_set_mask_length)
+
     @property
     def device(self):
         if self.interface:

+ 1 - 0
netbox/netbox/forms.py

@@ -15,6 +15,7 @@ OBJ_TYPE_CHOICES = (
         ('devicetype', 'Device types'),
         ('device', 'Devices'),
         ('virtualchassis', 'Virtual Chassis'),
+        ('cable', 'Cables'),
     )),
     ('IPAM', (
         ('vrf', 'VRFs'),

+ 9 - 1
netbox/netbox/settings.py

@@ -22,7 +22,7 @@ except ImportError:
     )
 
 
-VERSION = '2.5.2'
+VERSION = '2.5.3'
 
 BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
 
@@ -246,6 +246,14 @@ LOGIN_URL = '/{}login/'.format(BASE_PATH)
 # Secrets
 SECRETS_MIN_PUBKEY_SIZE = 2048
 
+# Pagination
+PER_PAGE_DEFAULTS = [
+    25, 50, 100, 250, 500, 1000
+]
+if PAGINATE_COUNT not in PER_PAGE_DEFAULTS:
+    PER_PAGE_DEFAULTS.append(PAGINATE_COUNT)
+    PER_PAGE_DEFAULTS = sorted(PER_PAGE_DEFAULTS)
+
 # Django filters
 FILTERS_NULL_CHOICE_LABEL = 'None'
 FILTERS_NULL_CHOICE_VALUE = 'null'

+ 8 - 2
netbox/netbox/views.py

@@ -11,13 +11,13 @@ from circuits.filters import CircuitFilter, ProviderFilter
 from circuits.models import Circuit, Provider
 from circuits.tables import CircuitTable, ProviderTable
 from dcim.filters import (
-    DeviceFilter, DeviceTypeFilter, RackFilter, RackGroupFilter, SiteFilter, VirtualChassisFilter
+    CableFilter, DeviceFilter, DeviceTypeFilter, RackFilter, RackGroupFilter, SiteFilter, VirtualChassisFilter
 )
 from dcim.models import (
     Cable, ConsolePort, Device, DeviceType, Interface, PowerPort, Rack, RackGroup, Site, VirtualChassis
 )
 from dcim.tables import (
-    DeviceDetailTable, DeviceTypeTable, RackTable, RackGroupTable, SiteTable, VirtualChassisTable
+    CableTable, DeviceDetailTable, DeviceTypeTable, RackTable, RackGroupTable, SiteTable, VirtualChassisTable
 )
 from extras.models import ObjectChange, ReportResult, TopologyMap
 from ipam.filters import AggregateFilter, IPAddressFilter, PrefixFilter, VLANFilter, VRFFilter
@@ -88,6 +88,12 @@ SEARCH_TYPES = OrderedDict((
         'table': VirtualChassisTable,
         'url': 'dcim:virtualchassis_list',
     }),
+    ('cable', {
+        'queryset': Cable.objects.all(),
+        'filter': CableFilter,
+        'table': CableTable,
+        'url': 'dcim:cable_list',
+    }),
     # IPAM
     ('vrf', {
         'queryset': VRF.objects.select_related('tenant'),

+ 3 - 0
netbox/project-static/css/base.css

@@ -140,6 +140,9 @@ table.attr-table td:nth-child(1) {
 div.paginator {
     margin-bottom: 20px;
 }
+div.paginator form {
+    margin-bottom: 6px;
+}
 nav ul.pagination {
     margin-top: 0;
     margin-bottom: 8px !important;

+ 5 - 0
netbox/project-static/js/forms.js

@@ -1,5 +1,10 @@
 $(document).ready(function() {
 
+    // Pagination
+    $('select#per_page').change(function() {
+        this.form.submit();
+    });
+
     // "Toggle" checkbox for object lists (PK column)
     $('input:checkbox.toggle').click(function() {
         $(this).closest('table').find('input:checkbox[name=pk]').prop('checked', $(this).prop('checked'));

+ 1 - 1
netbox/templates/extras/report_list.html

@@ -38,7 +38,7 @@
                                         <td colspan="3" class="method">
                                             {{ method }}
                                         </td>
-                                        <td class="text-right report-stats">
+                                        <td class="text-right text-nowrap report-stats">
                                             <label class="label label-success">{{ stats.success }}</label>
                                             <label class="label label-info">{{ stats.info }}</label>
                                             <label class="label label-warning">{{ stats.warning }}</label>

+ 13 - 1
netbox/templates/inc/paginator.html

@@ -1,6 +1,6 @@
 {% load helpers %}
 
-<div class="paginator pull-right">
+<div class="paginator pull-right text-right">
     {% if paginator.num_pages > 1 %}
         <nav>
             <ul class="pagination pull-right">
@@ -19,6 +19,18 @@
                 {% endif %}
             </ul>
         </nav>
+        <form method="get">
+            {% for k, v in request.GET.items %}
+                {% if k != 'per_page' %}
+                    <input type="hidden" name="{{ k }}" value="{{ v }}" />
+                {% endif %}
+            {% endfor %}
+            <select name="per_page" id="per_page">
+                {% for n in settings.PER_PAGE_DEFAULTS %}
+                    <option value="{{ n }}"{% if page.paginator.per_page == n %} selected="selected"{% endif %}>{{ n }}</option>
+                {% endfor %}
+            </select> per page
+        </form>
     {% endif %}
     {% if page %}
         <div class="text-right text-muted">

+ 0 - 3
netbox/templates/responsive_table.html

@@ -3,6 +3,3 @@
 <div class="table-responsive">
     {% render_table table 'inc/table.html' %}
 </div>
-{% with paginator=table.paginator page=table.page %}
-    {% include 'inc/paginator.html' %}
-{% endwith %}

+ 14 - 11
netbox/templates/utilities/obj_table.html

@@ -28,19 +28,22 @@
             </div>
         {% endif %}
         {% include table_template|default:'responsive_table.html' %}
-        {% block extra_actions %}{% endblock %}
-        {% if bulk_edit_url and permissions.change %}
-            <button type="submit" name="_edit" formaction="{% url bulk_edit_url %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-warning btn-sm">
-                <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit Selected
-            </button>
-        {% endif %}
-        {% if bulk_delete_url and permissions.delete %}
-            <button type="submit" name="_delete" formaction="{% url bulk_delete_url %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-danger btn-sm">
-                <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete Selected
-            </button>
-        {% endif %}
+        <div class="pull-left">
+            {% block extra_actions %}{% endblock %}
+            {% if bulk_edit_url and permissions.change %}
+                <button type="submit" name="_edit" formaction="{% url bulk_edit_url %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-warning btn-sm">
+                    <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit Selected
+                </button>
+            {% endif %}
+            {% if bulk_delete_url and permissions.delete %}
+                <button type="submit" name="_delete" formaction="{% url bulk_delete_url %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-danger btn-sm">
+                    <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete Selected
+                </button>
+            {% endif %}
+        </div>
     </form>
 {% else %}
     {% include table_template|default:'responsive_table.html' %}
 {% endif %}
+    {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
 <div class="clearfix"></div>

+ 3 - 0
netbox/utilities/constants.py

@@ -2,6 +2,7 @@ COLOR_CHOICES = (
     ('aa1409', 'Dark red'),
     ('f44336', 'Red'),
     ('e91e63', 'Pink'),
+    ('ffe4e1', 'Rose'),
     ('ff66ff', 'Fuschia'),
     ('9c27b0', 'Purple'),
     ('673ab7', 'Dark purple'),
@@ -10,6 +11,7 @@ COLOR_CHOICES = (
     ('03a9f4', 'Light blue'),
     ('00bcd4', 'Cyan'),
     ('009688', 'Teal'),
+    ('00ffff', 'Aqua'),
     ('2f6a31', 'Dark green'),
     ('4caf50', 'Green'),
     ('8bc34a', 'Light green'),
@@ -23,4 +25,5 @@ COLOR_CHOICES = (
     ('9e9e9e', 'Grey'),
     ('607d8b', 'Dark grey'),
     ('111111', 'Black'),
+    ('ffffff', 'White'),
 )

+ 5 - 3
netbox/utilities/forms.py

@@ -505,8 +505,9 @@ class FilterChoiceIterator(forms.models.ModelChoiceIterator):
 class FilterChoiceFieldMixin(object):
     iterator = FilterChoiceIterator
 
-    def __init__(self, null_label=None, *args, **kwargs):
+    def __init__(self, null_label=None, count_attr='filter_count', *args, **kwargs):
         self.null_label = null_label
+        self.count_attr = count_attr
         if 'required' not in kwargs:
             kwargs['required'] = False
         if 'widget' not in kwargs:
@@ -515,8 +516,9 @@ class FilterChoiceFieldMixin(object):
 
     def label_from_instance(self, obj):
         label = super().label_from_instance(obj)
-        if hasattr(obj, 'filter_count'):
-            return '{} ({})'.format(label, obj.filter_count)
+        obj_count = getattr(obj, self.count_attr, None)
+        if obj_count is not None:
+            return '{} ({})'.format(label, obj_count)
         return label