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

This commit is contained in:
Stefan de Kooter 2021-08-31 21:18:35 +02:00
commit 4618cc2b22
No known key found for this signature in database
GPG Key ID: 340B95E679E5217B
22 changed files with 108 additions and 58 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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
# Enforce maximum page size
if settings.MAX_PAGE_SIZE:
limit = min(limit, settings.MAX_PAGE_SIZE)
return self.default_limit
return limit
def get_next_link(self):

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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