Просмотр исходного кода

Merge branch 'develop' of github.com:netbox-community/netbox into 7090-fix-cablebulkedit-length-field

Stefan de Kooter 4 лет назад
Родитель
Сommit
4618cc2b22

+ 10 - 1
docs/release-notes/version-3.0.md

@@ -1,11 +1,20 @@
 # NetBox v3.0
 
-## v2.11.12 (2021-08-23)
+## v3.0.1 (FUTURE)
 
 ### Bug Fixes
 
 * [#7070](https://github.com/netbox-community/netbox/issues/7070) - Fix exception when filtering by prefix max length in UI
 * [#7071](https://github.com/netbox-community/netbox/issues/7071) - Fix exception when removing a primary IP from a device/VM
+* [#7072](https://github.com/netbox-community/netbox/issues/7072) - Fix table configuration under prefix child object views
+* [#7075](https://github.com/netbox-community/netbox/issues/7075) - Fix UI bug when a custom field has a space in the name
+* [#7082](https://github.com/netbox-community/netbox/issues/7082) - Avoid exception when referencing invalid content type in table
+* [#7083](https://github.com/netbox-community/netbox/issues/7083) - Correct labeling for VM memory attribute
+* [#7084](https://github.com/netbox-community/netbox/issues/7084) - Fix KeyError exception when editing access VLAN on an interface
+* [#7089](https://github.com/netbox-community/netbox/issues/7089) - Fix ContentTypeFilterSet not filtering on q filter
+* [#7093](https://github.com/netbox-community/netbox/issues/7093) - Multi-select custom field filters should employ exact match
+* [#7096](https://github.com/netbox-community/netbox/issues/7096) - Home links should honor `BASE_PATH` configuration
+* [#7101](https://github.com/netbox-community/netbox/issues/7101) - Enforce `MAX_PAGE_SIZE` for table and REST API pagination
 
 ---
 

+ 2 - 2
netbox/dcim/forms.py

@@ -129,7 +129,7 @@ class InterfaceCommonForm(forms.Form):
         super().clean()
 
         parent_field = 'device' if 'device' in self.cleaned_data else 'virtual_machine'
-        tagged_vlans = self.cleaned_data['tagged_vlans']
+        tagged_vlans = self.cleaned_data.get('tagged_vlans')
 
         # Untagged interfaces cannot be assigned tagged VLANs
         if self.cleaned_data['mode'] == InterfaceModeChoices.MODE_ACCESS and tagged_vlans:
@@ -142,7 +142,7 @@ class InterfaceCommonForm(forms.Form):
             self.cleaned_data['tagged_vlans'] = []
 
         # Validate tagged VLANs; must be a global VLAN or in the same site
-        elif self.cleaned_data['mode'] == InterfaceModeChoices.MODE_TAGGED:
+        elif self.cleaned_data['mode'] == InterfaceModeChoices.MODE_TAGGED and tagged_vlans:
             valid_sites = [None, self.cleaned_data[parent_field].site]
             invalid_vlans = [str(v) for v in tagged_vlans if v.site not in valid_sites]
 

+ 4 - 1
netbox/extras/filters.py

@@ -14,6 +14,7 @@ EXACT_FILTER_TYPES = (
     CustomFieldTypeChoices.TYPE_DATE,
     CustomFieldTypeChoices.TYPE_INTEGER,
     CustomFieldTypeChoices.TYPE_SELECT,
+    CustomFieldTypeChoices.TYPE_MULTISELECT,
 )
 
 
@@ -35,7 +36,9 @@ class CustomFieldFilter(django_filters.Filter):
 
         self.field_name = f'custom_field_data__{self.field_name}'
 
-        if custom_field.type not in EXACT_FILTER_TYPES:
+        if custom_field.type == CustomFieldTypeChoices.TYPE_MULTISELECT:
+            self.lookup_expr = 'has_key'
+        elif custom_field.type not in EXACT_FILTER_TYPES:
             if custom_field.filter_logic == CustomFieldFilterLogicChoices.FILTER_LOOSE:
                 self.lookup_expr = 'icontains'
 

+ 12 - 0
netbox/extras/filtersets.py

@@ -367,7 +367,19 @@ class JobResultFilterSet(BaseFilterSet):
 #
 
 class ContentTypeFilterSet(django_filters.FilterSet):
+    q = django_filters.CharFilter(
+        method='search',
+        label='Search',
+    )
 
     class Meta:
         model = ContentType
         fields = ['id', 'app_label', 'model']
+
+    def search(self, queryset, name, value):
+        if not value.strip():
+            return queryset
+        return queryset.filter(
+            Q(app_label__icontains=value) |
+            Q(model__icontains=value)
+        )

+ 16 - 3
netbox/extras/tests/test_customfields.py

@@ -681,7 +681,12 @@ class CustomFieldFilterTest(TestCase):
         cf.content_types.set([obj_type])
 
         # Selection filtering
-        cf = CustomField(name='cf8', type=CustomFieldTypeChoices.TYPE_URL, choices=['Foo', 'Bar', 'Baz'])
+        cf = CustomField(name='cf8', type=CustomFieldTypeChoices.TYPE_SELECT, choices=['Foo', 'Bar', 'Baz'])
+        cf.save()
+        cf.content_types.set([obj_type])
+
+        # Multiselect filtering
+        cf = CustomField(name='cf9', type=CustomFieldTypeChoices.TYPE_MULTISELECT, choices=['A', 'AA', 'B', 'C'])
         cf.save()
         cf.content_types.set([obj_type])
 
@@ -695,6 +700,7 @@ class CustomFieldFilterTest(TestCase):
                 'cf6': 'http://foo.example.com/',
                 'cf7': 'http://foo.example.com/',
                 'cf8': 'Foo',
+                'cf9': ['A', 'B'],
             }),
             Site(name='Site 2', slug='site-2', custom_field_data={
                 'cf1': 200,
@@ -705,9 +711,9 @@ class CustomFieldFilterTest(TestCase):
                 'cf6': 'http://bar.example.com/',
                 'cf7': 'http://bar.example.com/',
                 'cf8': 'Bar',
+                'cf9': ['AA', 'B'],
             }),
-            Site(name='Site 3', slug='site-3', custom_field_data={
-            }),
+            Site(name='Site 3', slug='site-3'),
         ])
 
     def test_filter_integer(self):
@@ -730,3 +736,10 @@ class CustomFieldFilterTest(TestCase):
 
     def test_filter_select(self):
         self.assertEqual(self.filterset({'cf_cf8': 'Foo'}, self.queryset).qs.count(), 1)
+        self.assertEqual(self.filterset({'cf_cf8': 'Bar'}, self.queryset).qs.count(), 1)
+        self.assertEqual(self.filterset({'cf_cf8': 'Baz'}, self.queryset).qs.count(), 0)
+
+    def test_filter_multiselect(self):
+        self.assertEqual(self.filterset({'cf_cf9': 'A'}, self.queryset).qs.count(), 1)
+        self.assertEqual(self.filterset({'cf_cf9': 'B'}, self.queryset).qs.count(), 2)
+        self.assertEqual(self.filterset({'cf_cf9': 'C'}, self.queryset).qs.count(), 0)

+ 4 - 5
netbox/ipam/views.py

@@ -404,12 +404,11 @@ class PrefixPrefixesView(generic.ObjectView):
         bulk_querystring = 'vrf_id={}&within={}'.format(instance.vrf.pk if instance.vrf else '0', instance.prefix)
 
         return {
-            'first_available_prefix': instance.get_first_available_prefix(),
             'table': table,
             'bulk_querystring': bulk_querystring,
             'active_tab': 'prefixes',
+            'first_available_prefix': instance.get_first_available_prefix(),
             'show_available': request.GET.get('show_available', 'true') == 'true',
-            'table_config_form': TableConfigForm(table=table),
         }
 
 
@@ -421,7 +420,7 @@ class PrefixIPRangesView(generic.ObjectView):
         # Find all IPRanges belonging to this Prefix
         ip_ranges = instance.get_child_ranges().restrict(request.user, 'view').prefetch_related('vrf')
 
-        table = tables.IPRangeTable(ip_ranges)
+        table = tables.IPRangeTable(ip_ranges, user=request.user)
         if request.user.has_perm('ipam.change_iprange') or request.user.has_perm('ipam.delete_iprange'):
             table.columns.show('pk')
         paginate_table(table, request)
@@ -449,7 +448,7 @@ class PrefixIPAddressesView(generic.ObjectView):
         if request.GET.get('show_available', 'true') == 'true':
             ipaddresses = add_available_ipaddresses(instance.prefix, ipaddresses, instance.is_pool)
 
-        table = tables.IPAddressTable(ipaddresses)
+        table = tables.IPAddressTable(ipaddresses, user=request.user)
         if request.user.has_perm('ipam.change_ipaddress') or request.user.has_perm('ipam.delete_ipaddress'):
             table.columns.show('pk')
         paginate_table(table, request)
@@ -457,10 +456,10 @@ class PrefixIPAddressesView(generic.ObjectView):
         bulk_querystring = 'vrf_id={}&parent={}'.format(instance.vrf.pk if instance.vrf else '0', instance.prefix)
 
         return {
-            'first_available_ip': instance.get_first_available_ip(),
             'table': table,
             'bulk_querystring': bulk_querystring,
             'active_tab': 'ip-addresses',
+            'first_available_ip': instance.get_first_available_ip(),
             'show_available': request.GET.get('show_available', 'true') == 'true',
         }
 

+ 6 - 16
netbox/netbox/api/pagination.py

@@ -34,23 +34,13 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination):
             return list(queryset[self.offset:])
 
     def get_limit(self, request):
+        limit = super().get_limit(request)
 
-        if self.limit_query_param:
-            try:
-                limit = int(request.query_params[self.limit_query_param])
-                if limit < 0:
-                    raise ValueError()
-                # Enforce maximum page size, if defined
-                if settings.MAX_PAGE_SIZE:
-                    if limit == 0:
-                        return settings.MAX_PAGE_SIZE
-                    else:
-                        return min(limit, settings.MAX_PAGE_SIZE)
-                return limit
-            except (KeyError, ValueError):
-                pass
-
-        return self.default_limit
+        # Enforce maximum page size
+        if settings.MAX_PAGE_SIZE:
+            limit = min(limit, settings.MAX_PAGE_SIZE)
+
+        return limit
 
     def get_next_link(self):
 

+ 4 - 0
netbox/netbox/settings.py

@@ -560,6 +560,10 @@ RQ_QUEUES = {
 #
 
 # Pagination
+if MAX_PAGE_SIZE and PAGINATE_COUNT > MAX_PAGE_SIZE:
+    raise ImproperlyConfigured(
+        f"PAGINATE_COUNT ({PAGINATE_COUNT}) must be less than or equal to MAX_PAGE_SIZE ({MAX_PAGE_SIZE}), if set."
+    )
 PER_PAGE_DEFAULTS = [
     25, 50, 100, 250, 500, 1000
 ]

+ 0 - 1
netbox/netbox/views/generic.py

@@ -181,7 +181,6 @@ class ObjectListView(ObjectPermissionRequiredMixin, View):
             'table': table,
             'permissions': permissions,
             'action_buttons': self.action_buttons,
-            'table_config_form': TableConfigForm(table=table),
             'filter_form': self.filterset_form(request.GET, label_suffix='') if self.filterset_form else None,
         }
         context.update(self.extra_context())

+ 3 - 3
netbox/templates/500.html

@@ -4,7 +4,7 @@
 
 <head>
     <title>Server Error</title>
-    <link rel="stylesheet" href="{% static 'netbox.css'%}" />
+    <link rel="stylesheet" href="{% static 'netbox-light.css'%}" />
     <meta charset="UTF-8">
 </head>
 
@@ -12,7 +12,7 @@
     <div class="container-fluid">
         <div class="row">
             <div class="col col-md-6 offset-md-3">
-                <div class="card bg-danger mt-5">
+                <div class="card border-danger mt-5">
                     <h5 class="card-header">
                         <i class="mdi mdi-alert"></i> Server Error
                     </h5>
@@ -32,7 +32,7 @@
 Python version: {{ python_version }}
 NetBox version: {{ netbox_version }}</pre>
                         <p>
-                            If further assistance is required, please post to the <a href="https://groups.google.com/g/netbox-discuss">NetBox mailing list</a>.
+                            If further assistance is required, please post to the <a href="https://github.com/netbox-community/netbox/discussions">NetBox discussion forum</a> on GitHub.
                         </p>
                         <div class="text-end">
                             <a href="{% url 'home' %}" class="btn btn-primary">Home Page</a>

+ 2 - 2
netbox/templates/base/sidenav.html

@@ -7,12 +7,12 @@
   {# Brand #}
 
     {# Full Logo #}
-    <a class="sidenav-brand" href="/">
+    <a class="sidenav-brand" href="{% url 'home' %}">
       <img src="{% static 'netbox_logo.svg' %}" height="48" class="sidenav-brand-img" alt="NetBox Logo">
     </a>
 
     {# Icon Logo #}
-    <a class="sidenav-brand-icon" href="/">
+    <a class="sidenav-brand-icon" href="{% url 'home' %}">
       <img src="{% static 'netbox_icon.svg' %}" height="32" class="sidenav-brand-img" alt="NetBox Logo">
     </a>
 

+ 8 - 0
netbox/templates/ipam/prefix/ip_addresses.html

@@ -1,4 +1,6 @@
 {% extends 'ipam/prefix/base.html' %}
+{% load helpers %}
+{% load static %}
 
 {% block extra_controls %}
   {% if perms.ipam.add_ipaddress and active_tab == 'ip-addresses' and first_available_ip %}
@@ -11,7 +13,13 @@
 {% block content %}
   <div class="row">
     <div class="col col-md-12">
+      {% include 'inc/table_controls.html' with table_modal="IPAddressTable_config" %}
       {% include 'utilities/obj_table.html' with heading='IP Addresses' bulk_edit_url='ipam:ipaddress_bulk_edit' bulk_delete_url='ipam:ipaddress_bulk_delete' %}
     </div>
   </div>
+  {% table_config_form table table_name="IPAddressTable" %}
+{% endblock %}
+
+{% block javascript %}
+  <script src="{% static 'js/tableconfig.js' %}"></script>
 {% endblock %}

+ 8 - 1
netbox/templates/ipam/prefix/ip_ranges.html

@@ -1,10 +1,17 @@
 {% extends 'ipam/prefix/base.html' %}
-
+{% load helpers %}
+{% load static %}
 
 {% block content %}
   <div class="row">
     <div class="col col-md-12">
+      {% include 'inc/table_controls.html' with table_modal="IPRangeTable_config" %}
       {% include 'utilities/obj_table.html' with heading='Child IP Ranges' bulk_edit_url='ipam:prefix_bulk_edit' bulk_delete_url='ipam:prefix_bulk_delete' parent=prefix %}
     </div>
   </div>
+  {% table_config_form table table_name="IPRangeTable" %}
+{% endblock %}
+
+{% block javascript %}
+  <script src="{% static 'js/tableconfig.js' %}"></script>
 {% endblock %}

+ 7 - 9
netbox/templates/ipam/prefix/prefixes.html

@@ -2,20 +2,17 @@
 {% load helpers %}
 {% load static %}
 
-{% block buttons %}
+{% block extra_controls %}
   {% include 'ipam/inc/toggle_available.html' %}
-  {% if request.user.is_authenticated and table_config_form %}
-      <button type="button" class="btn btn-default" data-toggle="modal" data-target="#PrefixDetailTable_config" title="Configure table"><i class="mdi mdi-cog"></i> Configure</button>
-  {% endif %}
   {% if perms.ipam.add_prefix and active_tab == 'prefixes' and first_available_prefix %}
-    <a href="{% url 'ipam:prefix_add' %}?prefix={{ first_available_prefix }}&vrf={{ object.vrf.pk }}&site={{ object.site.pk }}&tenant_group={{ object.tenant.group.pk }}&tenant={{ object.tenant.pk }}" class="btn btn-success">
+    <a href="{% url 'ipam:prefix_add' %}?prefix={{ first_available_prefix }}&vrf={{ object.vrf.pk }}&site={{ object.site.pk }}&tenant_group={{ object.tenant.group.pk }}&tenant={{ object.tenant.pk }}" class="btn btn-sm btn-success">
       <i class="mdi mdi-plus-thick" aria-hidden="true"></i> Add Child Prefix
     </a>
   {% endif %}
   {% if perms.ipam.add_ipaddress and active_tab == 'ip-addresses' and first_available_ip %}
-    <a href="{% url 'ipam:ipaddress_add' %}?address={{ first_available_ip }}&vrf={{ object.vrf.pk }}&tenant_group={{ object.tenant.group.pk }}&tenant={{ object.tenant.pk }}" class="btn btn-success">
+    <a href="{% url 'ipam:ipaddress_add' %}?address={{ first_available_ip }}&vrf={{ object.vrf.pk }}&tenant_group={{ object.tenant.group.pk }}&tenant={{ object.tenant.pk }}" class="btn btn-sm btn-success">
       <span class="mdi mdi-plus-thick" aria-hidden="true"></span>
-      Add an IP Address
+      Add Child IP Address
     </a>
   {% endif %}
   {{ block.super }}
@@ -24,12 +21,13 @@
 {% block content %}
   <div class="row">
     <div class="col col-md-12">
+      {% include 'inc/table_controls.html' with table_modal="PrefixDetailTable_config" %}
       {% include 'utilities/obj_table.html' with heading='Child Prefixes' bulk_edit_url='ipam:prefix_bulk_edit' bulk_delete_url='ipam:prefix_bulk_delete' parent=prefix %}
     </div>
   </div>
-  {% table_config_form prefix_table table_name="PrefixDetailTable" %}
+  {% table_config_form table table_name="PrefixDetailTable" %}
 {% endblock %}
 
 {% block javascript %}
-<script src="{% static 'js/tableconfig.js' %}"></script>
+  <script src="{% static 'js/tableconfig.js' %}"></script>
 {% endblock %}

+ 1 - 1
netbox/templates/media_failure.html

@@ -42,7 +42,7 @@
                 The file <code>{{ filename }}</code> exists in the static root directory and is readable by the HTTP process.
             </li>
         </ul>
-        <p>Click <a href="/">here</a> to attempt loading NetBox again.</p>
+        <p>Click <a href="{% url 'home' %}">here</a> to attempt loading NetBox again.</p>
     </div>
 </body>
 </html>

+ 1 - 1
netbox/templates/rest_framework/api.html

@@ -9,5 +9,5 @@
 {% block title %}{% if name %}{{ name }} | {% endif %}NetBox REST API{% endblock %}
 
 {% block branding %}
-  <a class="navbar-brand" href="/{{ settings.BASE_PATH }}">NetBox</a>
+  <a class="navbar-brand" href="{% url 'home' %}">NetBox</a>
 {% endblock branding %}

+ 5 - 5
netbox/templates/utilities/templatetags/table_config_form.html

@@ -7,11 +7,11 @@
         <h5 class="modal-title">Table Configuration</h5>
         <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
       </div>
-      <form class="form-horizontal userconfigform" data-config-root="tables.{{ table_config_form.table_name }}">
+      <form class="form-horizontal userconfigform" data-config-root="tables.{{ form.table_name }}">
         <div class="modal-body row">
           <div class="col-5 text-center">
-            {{ table_config_form.available_columns.label }}
-            {{ table_config_form.available_columns }}
+            {{ form.available_columns.label }}
+            {{ form.available_columns }}
           </div>
           <div class="col-2 d-flex align-items-center">
             <div>
@@ -24,8 +24,8 @@
             </div>
           </div>
           <div class="col-5 text-center">
-            {{ table_config_form.columns.label }}
-            {{ table_config_form.columns }}
+            {{ form.columns.label }}
+            {{ form.columns }}
             <a class="btn btn-primary btn-sm mt-2" id="move-option-up" data-target="id_columns">
                 <i class="mdi mdi-arrow-up-bold"></i> Move Up
             </a>

+ 1 - 1
netbox/templates/virtualization/virtualmachine.html

@@ -131,7 +131,7 @@
                         <th scope="row"><i class="mdi mdi-chip"></i> Memory</th>
                         <td>
                             {% if object.memory %}
-                                {{ object.memory|humanize_megabytes }} MB
+                                {{ object.memory|humanize_megabytes }}
                             {% else %}
                                 <span class="text-muted">&mdash;</span>
                             {% endif %}

+ 2 - 2
netbox/utilities/management/commands/makemigrations.py

@@ -21,8 +21,8 @@ class Command(_Command):
             raise CommandError(
                 "This command is available for development purposes only. It will\n"
                 "NOT resolve any issues with missing or unapplied migrations. For assistance,\n"
-                "please post to the NetBox mailing list:\n"
-                "    https://groups.google.com/g/netbox-discuss"
+                "please post to the NetBox discussion forum on GitHub:\n"
+                "    https://github.com/netbox-community/netbox/discussions"
             )
 
         super().handle(*args, **kwargs)

+ 8 - 4
netbox/utilities/paginator.py

@@ -49,21 +49,25 @@ class EnhancedPage(Page):
 
 def get_paginate_count(request):
     """
-    Determine the length of a page, using the following in order:
+    Determine the desired length of a page, using the following in order:
 
         1. per_page URL query parameter
         2. Saved user preference
         3. PAGINATE_COUNT global setting.
+
+    Return the lesser of the calculated value and MAX_PAGE_SIZE.
     """
     if 'per_page' in request.GET:
         try:
             per_page = int(request.GET.get('per_page'))
             if request.user.is_authenticated:
                 request.user.config.set('pagination.per_page', per_page, commit=True)
-            return per_page
+            return min(per_page, settings.MAX_PAGE_SIZE)
         except ValueError:
             pass
 
     if request.user.is_authenticated:
-        return request.user.config.get('pagination.per_page', settings.PAGINATE_COUNT)
-    return settings.PAGINATE_COUNT
+        per_page = request.user.config.get('pagination.per_page', settings.PAGINATE_COUNT)
+        return min(per_page, settings.MAX_PAGE_SIZE)
+
+    return min(settings.PAGINATE_COUNT, settings.MAX_PAGE_SIZE)

+ 4 - 0
netbox/utilities/tables.py

@@ -237,9 +237,13 @@ class ContentTypeColumn(tables.Column):
     Display a ContentType instance.
     """
     def render(self, value):
+        if value is None:
+            return None
         return content_type_name(value)
 
     def value(self, value):
+        if value is None:
+            return None
         return f"{value.app_label}.{value.model}"
 
 

+ 1 - 1
netbox/utilities/templatetags/helpers.py

@@ -401,7 +401,7 @@ def badge(value, bg_class='secondary', show_empty=False):
 def table_config_form(table, table_name=None):
     return {
         'table_name': table_name or table.__class__.__name__,
-        'table_config_form': TableConfigForm(table=table),
+        'form': TableConfigForm(table=table),
     }