Merge branch 'develop' of github.com:digitalocean/netbox into select2-ui

This commit is contained in:
John Anderson 2019-01-04 12:44:41 -05:00
commit fca97f9768
17 changed files with 160 additions and 32 deletions

View File

@ -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) v2.5.2 (2018-12-21)
## Enhancements ## Enhancements

View File

@ -62,14 +62,14 @@ class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet):
choices=SITE_STATUS_CHOICES, choices=SITE_STATUS_CHOICES,
null_value=None null_value=None
) )
region_id = django_filters.ModelMultipleChoiceFilter( region_id = django_filters.NumberFilter(
queryset=Region.objects.all(), method='filter_region',
field_name='pk',
label='Region (ID)', label='Region (ID)',
) )
region = django_filters.ModelMultipleChoiceFilter( region = django_filters.CharFilter(
field_name='region__slug', method='filter_region',
queryset=Region.objects.all(), field_name='slug',
to_field_name='slug',
label='Region (slug)', label='Region (slug)',
) )
tenant_id = django_filters.ModelMultipleChoiceFilter( tenant_id = django_filters.ModelMultipleChoiceFilter(
@ -108,6 +108,16 @@ class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet):
pass pass
return queryset.filter(qs_filter) 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): class RackGroupFilter(django_filters.FilterSet):
q = django_filters.CharFilter( q = django_filters.CharFilter(

View File

@ -58,6 +58,22 @@ class BulkRenameForm(forms.Form):
""" """
find = forms.CharField() find = forms.CharField()
replace = 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 required=False
) )
region = FilterTreeNodeMultipleChoiceField( region = FilterTreeNodeMultipleChoiceField(
queryset=Region.objects.annotate(filter_count=Count('sites')), queryset=Region.objects.all(),
to_field_name='slug', to_field_name='slug',
required=False, required=False,
count_attr='site_count'
) )
tenant = FilterChoiceField( tenant = FilterChoiceField(
queryset=Tenant.objects.annotate(filter_count=Count('sites')), queryset=Tenant.objects.annotate(filter_count=Count('sites')),
@ -1217,11 +1234,13 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm):
# Initialize helper selectors # Initialize helper selectors
instance = kwargs.get('instance') instance = kwargs.get('instance')
if 'initial' not in kwargs:
kwargs['initial'] = {}
# Using hasattr() instead of "is not None" to avoid RelatedObjectDoesNotExist on required field # Using hasattr() instead of "is not None" to avoid RelatedObjectDoesNotExist on required field
if instance and hasattr(instance, 'device_type'): if instance and hasattr(instance, 'device_type'):
initial = kwargs.get('initial', {}).copy() kwargs['initial']['manufacturer'] = instance.device_type.manufacturer
initial['manufacturer'] = instance.device_type.manufacturer if instance and instance.cluster is not None:
kwargs['initial'] = initial kwargs['initial']['cluster_group'] = instance.cluster.group
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)

View File

@ -201,6 +201,13 @@ class Region(MPTTModel, ChangeLoggedModel):
self.parent.name if self.parent else None, 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 # Sites

View File

@ -1,3 +1,5 @@
import re
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.mixins import PermissionRequiredMixin from django.contrib.auth.mixins import PermissionRequiredMixin
from django.core.paginator import EmptyPage, PageNotAnInteger from django.core.paginator import EmptyPage, PageNotAnInteger
@ -50,7 +52,16 @@ class BulkRenameView(GetReturnURLMixin, View):
if form.is_valid(): if form.is_valid():
for obj in selected_objects: 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: if '_apply' in request.POST:
for obj in selected_objects: for obj in selected_objects:
@ -124,7 +135,7 @@ class BulkDisconnectView(GetReturnURLMixin, View):
# #
class RegionListView(ObjectListView): class RegionListView(ObjectListView):
queryset = Region.objects.annotate(site_count=Count('sites')) queryset = Region.objects.all()
filter = filters.RegionFilter filter = filters.RegionFilter
filter_form = forms.RegionFilterForm filter_form = forms.RegionFilterForm
table = tables.RegionTable table = tables.RegionTable

View File

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

View File

@ -385,6 +385,15 @@ class Prefix(ChangeLoggedModel, CustomFieldModel):
self.description, 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): def get_status_class(self):
return STATUS_CHOICE_CLASSES[self.status] return STATUS_CHOICE_CLASSES[self.status]
@ -630,6 +639,15 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
self.description, 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 @property
def device(self): def device(self):
if self.interface: if self.interface:

View File

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

View File

@ -246,6 +246,14 @@ LOGIN_URL = '/{}login/'.format(BASE_PATH)
# Secrets # Secrets
SECRETS_MIN_PUBKEY_SIZE = 2048 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 # Django filters
FILTERS_NULL_CHOICE_LABEL = 'None' FILTERS_NULL_CHOICE_LABEL = 'None'
FILTERS_NULL_CHOICE_VALUE = 'null' FILTERS_NULL_CHOICE_VALUE = 'null'

View File

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
{% load helpers %} {% load helpers %}
<div class="paginator pull-right"> <div class="paginator pull-right text-right">
{% if paginator.num_pages > 1 %} {% if paginator.num_pages > 1 %}
<nav> <nav>
<ul class="pagination pull-right"> <ul class="pagination pull-right">
@ -19,6 +19,13 @@
{% endif %} {% endif %}
</ul> </ul>
</nav> </nav>
<form method="get">
<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 %} {% endif %}
{% if page %} {% if page %}
<div class="text-right text-muted"> <div class="text-right text-muted">

View File

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

View File

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

View File

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

View File

@ -558,8 +558,9 @@ class FilterChoiceIterator(forms.models.ModelChoiceIterator):
class FilterChoiceFieldMixin(object): class FilterChoiceFieldMixin(object):
iterator = FilterChoiceIterator 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.null_label = null_label
self.count_attr = count_attr
if 'required' not in kwargs: if 'required' not in kwargs:
kwargs['required'] = False kwargs['required'] = False
if 'widget' not in kwargs: if 'widget' not in kwargs:
@ -568,8 +569,9 @@ class FilterChoiceFieldMixin(object):
def label_from_instance(self, obj): def label_from_instance(self, obj):
label = super().label_from_instance(obj) label = super().label_from_instance(obj)
if hasattr(obj, 'filter_count'): obj_count = getattr(obj, self.count_attr, None)
return '{} ({})'.format(label, obj.filter_count) if obj_count is not None:
return '{} ({})'.format(label, obj_count)
return label return label