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)
## Enhancements

View File

@ -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(

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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:

View File

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

View 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'

View File

@ -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'),

View File

@ -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;

View File

@ -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'));

View File

@ -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,13 @@
{% endif %}
</ul>
</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 %}
{% if page %}
<div class="text-right text-muted">

View File

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

View File

@ -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>

View File

@ -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'),
)

View File

@ -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