diff --git a/CHANGELOG.md b/CHANGELOG.md index f1f5ee223..fd64207ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,21 @@ +v2.5.3 (FUTURE) + +## 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 +* [#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 + + +--- + v2.5.2 (2018-12-21) ## Enhancements diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index 41848d640..e10cfe337 100644 --- a/netbox/dcim/filters.py +++ b/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( diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index b6e1e4a77..d05a09597 100644 --- a/netbox/dcim/forms.py +++ b/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" + }) # @@ -241,9 +257,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')), @@ -1217,11 +1234,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) diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 347f0c8b8..567f9046e 100644 --- a/netbox/dcim/models.py +++ b/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 diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 1d74c1c85..4c2dee5df 100644 --- a/netbox/dcim/views.py +++ b/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 diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 570716532..b6209f5df 100644 --- a/netbox/ipam/forms.py +++ b/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 diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index 789e65b82..6f7a21236 100644 --- a/netbox/ipam/models.py +++ b/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] @@ -630,6 +639,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: diff --git a/netbox/netbox/forms.py b/netbox/netbox/forms.py index d5ab09410..a2ad1376b 100644 --- a/netbox/netbox/forms.py +++ b/netbox/netbox/forms.py @@ -15,6 +15,7 @@ OBJ_TYPE_CHOICES = ( ('devicetype', 'Device types'), ('device', 'Devices'), ('virtualchassis', 'Virtual Chassis'), + ('cable', 'Cables'), )), ('IPAM', ( ('vrf', 'VRFs'), diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 8651c3d26..e50e9bd72 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -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' diff --git a/netbox/netbox/views.py b/netbox/netbox/views.py index 263acb8ee..ff11e3892 100644 --- a/netbox/netbox/views.py +++ b/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'), diff --git a/netbox/project-static/css/base.css b/netbox/project-static/css/base.css index 155dd21b7..ad1b02e3f 100644 --- a/netbox/project-static/css/base.css +++ b/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; diff --git a/netbox/project-static/js/forms.js b/netbox/project-static/js/forms.js index 6fbaaac66..63ee70a07 100644 --- a/netbox/project-static/js/forms.js +++ b/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')); diff --git a/netbox/templates/inc/paginator.html b/netbox/templates/inc/paginator.html index 27d04c15c..e7be9eddb 100644 --- a/netbox/templates/inc/paginator.html +++ b/netbox/templates/inc/paginator.html @@ -1,6 +1,6 @@ {% load helpers %} -
+
{% if paginator.num_pages > 1 %} +
+ per page +
{% endif %} {% if page %}
diff --git a/netbox/templates/responsive_table.html b/netbox/templates/responsive_table.html index 81d9126fd..a6aaf5a6f 100644 --- a/netbox/templates/responsive_table.html +++ b/netbox/templates/responsive_table.html @@ -3,6 +3,3 @@
{% render_table table 'inc/table.html' %}
-{% with paginator=table.paginator page=table.page %} - {% include 'inc/paginator.html' %} -{% endwith %} diff --git a/netbox/templates/utilities/obj_table.html b/netbox/templates/utilities/obj_table.html index 058c7ef07..8c5e3b8be 100644 --- a/netbox/templates/utilities/obj_table.html +++ b/netbox/templates/utilities/obj_table.html @@ -28,19 +28,22 @@
{% endif %} {% include table_template|default:'responsive_table.html' %} - {% block extra_actions %}{% endblock %} - {% if bulk_edit_url and permissions.change %} - - {% endif %} - {% if bulk_delete_url and permissions.delete %} - - {% endif %} +
+ {% block extra_actions %}{% endblock %} + {% if bulk_edit_url and permissions.change %} + + {% endif %} + {% if bulk_delete_url and permissions.delete %} + + {% endif %} +
{% else %} {% include table_template|default:'responsive_table.html' %} {% endif %} + {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
diff --git a/netbox/utilities/constants.py b/netbox/utilities/constants.py index 64c2fab85..ad6e8fd90 100644 --- a/netbox/utilities/constants.py +++ b/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'), ) diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index eb003762b..462f92442 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -558,8 +558,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: @@ -568,8 +569,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