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 %} -