Explorar el Código

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

John Anderson hace 7 años
padre
commit
fca97f9768

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

+ 16 - 6
netbox/dcim/filters.py

@@ -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(
-        queryset=Region.objects.all(),
+    region_id = django_filters.NumberFilter(
+        method='filter_region',
+        field_name='pk',
         label='Region (ID)',
         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)',
         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(

+ 23 - 4
netbox/dcim/forms.py

@@ -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()
-            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)
         super().__init__(*args, **kwargs)
 
 

+ 7 - 0
netbox/dcim/models.py

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

+ 13 - 2
netbox/dcim/views.py

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

+ 10 - 0
netbox/ipam/forms.py

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

+ 18 - 0
netbox/ipam/models.py

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

+ 1 - 0
netbox/netbox/forms.py

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

+ 8 - 0
netbox/netbox/settings.py

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

+ 8 - 2
netbox/netbox/views.py

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

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

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

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

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

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

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

+ 0 - 3
netbox/templates/responsive_table.html

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

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

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

+ 3 - 0
netbox/utilities/constants.py

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

+ 5 - 3
netbox/utilities/forms.py

@@ -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'):
-            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
         return label