Merge branch 'develop' into 3122-connection-device-select2

This commit is contained in:
Saria Hajjar 2020-01-02 20:40:19 +00:00
commit 82c70302fd
14 changed files with 153 additions and 41 deletions

View File

@ -80,6 +80,7 @@ AUTH_LDAP_USER_ATTR_MAP = {
```
# User Groups for Permissions
!!! info
When using Microsoft Active Directory, support for nested groups can be activated by using `NestedGroupOfNamesType()` instead of `GroupOfNamesType()` for `AUTH_LDAP_GROUP_TYPE`. You will also need to modify the import line to use `NestedGroupOfNamesType` instead of `GroupOfNamesType` .
@ -117,6 +118,9 @@ AUTH_LDAP_GROUP_CACHE_TIMEOUT = 3600
* `is_staff` - Users mapped to this group are enabled for access to the administration tools; this is the equivalent of checking the "staff status" box on a manually created user. This doesn't grant any specific permissions.
* `is_superuser` - Users mapped to this group will be granted superuser status. Superusers are implicitly granted all permissions.
!!! warning
Authentication will fail if the groups (the distinguished names) do not exist in the LDAP directory.
# Troubleshooting LDAP
`supervisorctl restart netbox` restarts the Netbox service, and initiates any changes made to `ldap_config.py`. If there are syntax errors present, the NetBox process will not spawn an instance, and errors should be logged to `/var/log/supervisor/`.

View File

@ -2,9 +2,15 @@
## Enhancements
* [#2233](https://github.com/netbox-community/netbox/issues/2233) - Add ability to move inventory items between devices
* [#2892](https://github.com/netbox-community/netbox/issues/2892) - Extend admin UI to allow deleting old report results
* [#3062](https://github.com/netbox-community/netbox/issues/3062) - Add `assigned_to_interface` filter for IP addresses
* [#3461](https://github.com/netbox-community/netbox/issues/3461) - Fail gracefully on custom link rendering exception
* [#3705](https://github.com/netbox-community/netbox/issues/3705) - Provide request context when executing custom scripts
* [#3762](https://github.com/netbox-community/netbox/issues/3762) - Add date/time picker widgets
* [#3788](https://github.com/netbox-community/netbox/issues/3788) - Enable partial search for inventory items
* [#3812](https://github.com/netbox-community/netbox/issues/3812) - Optimize size of pages containing a dynamic selection field
* [#3827](https://github.com/netbox-community/netbox/issues/3827) - Allow filtering console/power/interface connections by device ID
## Bug Fixes
@ -14,6 +20,7 @@
* [#3780](https://github.com/netbox-community/netbox/issues/3780) - Fix AttributeError exception in API docs
* [#3809](https://github.com/netbox-community/netbox/issues/3809) - Filter platform by manufacturer when editing devices
* [#3811](https://github.com/netbox-community/netbox/issues/3811) - Fix filtering of racks by group on device list
* [#3822](https://github.com/netbox-community/netbox/issues/3822) - Fix exception when editing a device bay (regression from #3596)
---

View File

@ -978,9 +978,12 @@ class ConsoleConnectionFilter(django_filters.FilterSet):
method='filter_site',
label='Site (slug)',
)
device = django_filters.CharFilter(
device_id = MultiValueNumberFilter(
method='filter_device'
)
device = MultiValueCharFilter(
method='filter_device',
label='Device',
field_name='device__name'
)
class Meta:
@ -993,11 +996,11 @@ class ConsoleConnectionFilter(django_filters.FilterSet):
return queryset.filter(connected_endpoint__device__site__slug=value)
def filter_device(self, queryset, name, value):
if not value.strip():
if not value:
return queryset
return queryset.filter(
Q(device__name__icontains=value) |
Q(connected_endpoint__device__name__icontains=value)
Q(**{'{}__in'.format(name): value}) |
Q(**{'connected_endpoint__{}__in'.format(name): value})
)
@ -1006,9 +1009,12 @@ class PowerConnectionFilter(django_filters.FilterSet):
method='filter_site',
label='Site (slug)',
)
device = django_filters.CharFilter(
device_id = MultiValueNumberFilter(
method='filter_device'
)
device = MultiValueCharFilter(
method='filter_device',
label='Device',
field_name='device__name'
)
class Meta:
@ -1021,11 +1027,11 @@ class PowerConnectionFilter(django_filters.FilterSet):
return queryset.filter(_connected_poweroutlet__device__site__slug=value)
def filter_device(self, queryset, name, value):
if not value.strip():
if not value:
return queryset
return queryset.filter(
Q(device__name__icontains=value) |
Q(_connected_poweroutlet__device__name__icontains=value)
Q(**{'{}__in'.format(name): value}) |
Q(**{'_connected_poweroutlet__{}__in'.format(name): value})
)
@ -1034,9 +1040,12 @@ class InterfaceConnectionFilter(django_filters.FilterSet):
method='filter_site',
label='Site (slug)',
)
device = django_filters.CharFilter(
device_id = MultiValueNumberFilter(
method='filter_device'
)
device = MultiValueCharFilter(
method='filter_device',
label='Device',
field_name='device__name'
)
class Meta:
@ -1052,11 +1061,11 @@ class InterfaceConnectionFilter(django_filters.FilterSet):
)
def filter_device(self, queryset, name, value):
if not value.strip():
if not value:
return queryset
return queryset.filter(
Q(device__name__icontains=value) |
Q(_connected_interface__device__name__icontains=value)
Q(**{'{}__in'.format(name): value}) |
Q(**{'_connected_interface__{}__in'.format(name): value})
)

View File

@ -3297,9 +3297,12 @@ class InventoryItemForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = InventoryItem
fields = [
'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', 'tags',
'name', 'device', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', 'tags',
]
widgets = {
'device': APISelect(
api_url="/api/dcim/devices/"
),
'manufacturer': APISelect(
api_url="/api/dcim/manufacturers/"
)
@ -3335,9 +3338,19 @@ class InventoryItemBulkEditForm(BootstrapMixin, BulkEditForm):
queryset=InventoryItem.objects.all(),
widget=forms.MultipleHiddenInput()
)
device = forms.ModelChoiceField(
queryset=Device.objects.all(),
required=False,
widget=APISelect(
api_url="/api/dcim/devices/"
)
)
manufacturer = forms.ModelChoiceField(
queryset=Manufacturer.objects.all(),
required=False
required=False,
widget=APISelect(
api_url="/api/dcim/manufacturers/"
)
)
part_id = forms.CharField(
max_length=50,
@ -3371,11 +3384,14 @@ class InventoryItemFilterForm(BootstrapMixin, forms.Form):
manufacturer = FilterChoiceField(
queryset=Manufacturer.objects.all(),
to_field_name='slug',
null_label='-- None --'
widget=APISelect(
api_url="/api/dcim/manufacturers/",
value_field="slug",
)
)
discovered = forms.NullBooleanField(
required=False,
widget=forms.Select(
widget=StaticSelect2(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)

View File

@ -2597,7 +2597,7 @@ class DeviceBay(ComponentModel):
# Check that the installed device is not already installed elsewhere
if self.installed_device:
current_bay = DeviceBay.objects.filter(installed_device=self.installed_device).first()
if current_bay:
if current_bay and current_bay != self:
raise ValidationError({
'installed_device': "Cannot install the specified device; device is already installed in {}".format(
current_bay

View File

@ -3,7 +3,10 @@ from django.contrib import admin
from netbox.admin import admin_site
from utilities.forms import LaxURLField
from .models import CustomField, CustomFieldChoice, CustomLink, Graph, ExportTemplate, TopologyMap, Webhook
from .models import (
CustomField, CustomFieldChoice, CustomLink, Graph, ExportTemplate, ReportResult, TopologyMap, Webhook,
)
from .reports import get_report
def order_content_types(field):
@ -166,6 +169,36 @@ class ExportTemplateAdmin(admin.ModelAdmin):
form = ExportTemplateForm
#
# Reports
#
@admin.register(ReportResult, site=admin_site)
class ReportResultAdmin(admin.ModelAdmin):
list_display = [
'report', 'active', 'created', 'user', 'passing',
]
fields = [
'report', 'user', 'passing', 'data',
]
list_filter = [
'failed',
]
readonly_fields = fields
def has_add_permission(self, request):
return False
def active(self, obj):
module, report_name = obj.report.split('.')
return True if get_report(module, report_name) else False
active.boolean = True
def passing(self, obj):
return not obj.failed
passing.boolean = True
#
# Topology maps
#

View File

@ -52,7 +52,7 @@ def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=F
else:
initial = None
field = forms.NullBooleanField(
required=cf.required, initial=initial, widget=forms.Select(choices=choices)
required=cf.required, initial=initial, widget=StaticSelect2(choices=choices)
)
# Date
@ -71,7 +71,9 @@ def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=F
default_choice = cf.choices.get(value=initial).pk
except ObjectDoesNotExist:
pass
field = forms.TypedChoiceField(choices=choices, coerce=int, required=cf.required, initial=default_choice)
field = forms.TypedChoiceField(
choices=choices, coerce=int, required=cf.required, initial=default_choice, widget=StaticSelect2()
)
# URL
elif cf.type == CF_TYPE_URL:

View File

@ -915,6 +915,13 @@ class ReportResult(models.Model):
class Meta:
ordering = ['report']
def __str__(self):
return "{} {} at {}".format(
self.report,
"passed" if not self.failed else "failed",
self.created
)
#
# Change logging

View File

@ -46,12 +46,17 @@ def custom_links(obj):
# Add non-grouped links
else:
text_rendered = render_jinja2(cl.text, context)
if text_rendered:
link_target = ' target="_blank"' if cl.new_window else ''
template_code += LINK_BUTTON.format(
cl.url, link_target, cl.button_class, text_rendered
)
try:
text_rendered = render_jinja2(cl.text, context)
if text_rendered:
link_rendered = render_jinja2(cl.url, context)
link_target = ' target="_blank"' if cl.new_window else ''
template_code += LINK_BUTTON.format(
link_rendered, link_target, cl.button_class, text_rendered
)
except Exception as e:
template_code += '<a class="btn btn-sm btn-default" disabled="disabled" title="{}">' \
'<i class="fa fa-warning"></i> {}</a>\n'.format(e, cl.name)
# Add grouped links to template
for group, links in group_names.items():
@ -59,11 +64,17 @@ def custom_links(obj):
links_rendered = []
for cl in links:
text_rendered = render_jinja2(cl.text, context)
if text_rendered:
link_target = ' target="_blank"' if cl.new_window else ''
try:
text_rendered = render_jinja2(cl.text, context)
if text_rendered:
link_target = ' target="_blank"' if cl.new_window else ''
links_rendered.append(
GROUP_LINK.format(cl.url, link_target, cl.text)
)
except Exception as e:
links_rendered.append(
GROUP_LINK.format(cl.url, link_target, cl.text)
'<li><a disabled="disabled" title="{}"><span class="text-muted">'
'<i class="fa fa-warning"></i> {}</span></a></li>'.format(e, cl.name)
)
if links_rendered:
@ -71,7 +82,4 @@ def custom_links(obj):
links[0].button_class, group, ''.join(links_rendered)
)
# Render template
rendered = render_jinja2(template_code, context)
return mark_safe(rendered)
return mark_safe(template_code)

View File

@ -309,6 +309,10 @@ class IPAddressFilter(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilt
queryset=Interface.objects.all(),
label='Interface (ID)',
)
assigned_to_interface = django_filters.BooleanFilter(
method='_assigned_to_interface',
label='Is assigned to an interface',
)
status = django_filters.MultipleChoiceFilter(
choices=IPADDRESS_STATUS_CHOICES,
null_value=None
@ -366,6 +370,9 @@ class IPAddressFilter(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilt
except Device.DoesNotExist:
return queryset.none()
def _assigned_to_interface(self, queryset, name, value):
return queryset.exclude(interface__isnull=value)
class VLANGroupFilter(NameSlugSearchFilterSet):
site_id = django_filters.ModelMultipleChoiceFilter(

View File

@ -938,7 +938,8 @@ class IPAddressAssignForm(BootstrapMixin, forms.Form):
class IPAddressFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
model = IPAddress
field_order = [
'q', 'parent', 'family', 'mask_length', 'vrf_id', 'status', 'role', 'tenant_group', 'tenant',
'q', 'parent', 'family', 'mask_length', 'vrf_id', 'status', 'role', 'assigned_to_interface', 'tenant_group',
'tenant',
]
q = forms.CharField(
required=False,
@ -984,6 +985,13 @@ class IPAddressFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterFo
required=False,
widget=StaticSelect2Multiple()
)
assigned_to_interface = forms.NullBooleanField(
required=False,
label='Assigned to an interface',
widget=StaticSelect2(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
#

View File

@ -103,14 +103,16 @@ $(document).ready(function() {
placeholder: "---------",
theme: "bootstrap",
templateResult: colorPickerClassCopy,
templateSelection: colorPickerClassCopy
templateSelection: colorPickerClassCopy,
width: "off"
});
// Static choice selection
$('.netbox-select2-static').select2({
allowClear: true,
placeholder: "---------",
theme: "bootstrap"
theme: "bootstrap",
width: "off"
});
// API backed selection
@ -120,6 +122,7 @@ $(document).ready(function() {
allowClear: true,
placeholder: "---------",
theme: "bootstrap",
width: "off",
ajax: {
delay: 500,
@ -299,7 +302,8 @@ $(document).ready(function() {
multiple: true,
allowClear: true,
placeholder: "Tags",
theme: "bootstrap",
width: "off",
ajax: {
delay: 250,
url: netbox_api_path + "extras/tags/",

View File

@ -285,6 +285,8 @@ class APISelect(SelectWithDisabled):
name of the query param and the value if the query param's value.
:param null_option: If true, include the static null option in the selection list.
"""
# Only preload the selected option(s); new options are dynamically displayed and added via the API
template_name = 'widgets/select_api.html'
def __init__(
self,

View File

@ -0,0 +1,5 @@
<select name="{{ widget.name }}"{% include "django/forms/widgets/attrs.html" %}>{% for group_name, group_choices, group_index in widget.optgroups %}{% if group_name %}
<optgroup label="{{ group_name }}">{% endif %}{% for widget in group_choices %}{% if widget.attrs.selected %}
{% include widget.template_name %}{% endif %}{% endfor %}{% if group_name %}
</optgroup>{% endif %}{% endfor %}
</select>