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 # User Groups for Permissions
!!! info !!! 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` . 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_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. * `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 # 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/`. `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 ## 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 * [#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 * [#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 * [#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 ## Bug Fixes
@ -14,6 +20,7 @@
* [#3780](https://github.com/netbox-community/netbox/issues/3780) - Fix AttributeError exception in API docs * [#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 * [#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 * [#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', method='filter_site',
label='Site (slug)', label='Site (slug)',
) )
device = django_filters.CharFilter( device_id = MultiValueNumberFilter(
method='filter_device'
)
device = MultiValueCharFilter(
method='filter_device', method='filter_device',
label='Device', field_name='device__name'
) )
class Meta: class Meta:
@ -993,11 +996,11 @@ class ConsoleConnectionFilter(django_filters.FilterSet):
return queryset.filter(connected_endpoint__device__site__slug=value) return queryset.filter(connected_endpoint__device__site__slug=value)
def filter_device(self, queryset, name, value): def filter_device(self, queryset, name, value):
if not value.strip(): if not value:
return queryset return queryset
return queryset.filter( return queryset.filter(
Q(device__name__icontains=value) | Q(**{'{}__in'.format(name): value}) |
Q(connected_endpoint__device__name__icontains=value) Q(**{'connected_endpoint__{}__in'.format(name): value})
) )
@ -1006,9 +1009,12 @@ class PowerConnectionFilter(django_filters.FilterSet):
method='filter_site', method='filter_site',
label='Site (slug)', label='Site (slug)',
) )
device = django_filters.CharFilter( device_id = MultiValueNumberFilter(
method='filter_device'
)
device = MultiValueCharFilter(
method='filter_device', method='filter_device',
label='Device', field_name='device__name'
) )
class Meta: class Meta:
@ -1021,11 +1027,11 @@ class PowerConnectionFilter(django_filters.FilterSet):
return queryset.filter(_connected_poweroutlet__device__site__slug=value) return queryset.filter(_connected_poweroutlet__device__site__slug=value)
def filter_device(self, queryset, name, value): def filter_device(self, queryset, name, value):
if not value.strip(): if not value:
return queryset return queryset
return queryset.filter( return queryset.filter(
Q(device__name__icontains=value) | Q(**{'{}__in'.format(name): value}) |
Q(_connected_poweroutlet__device__name__icontains=value) Q(**{'_connected_poweroutlet__{}__in'.format(name): value})
) )
@ -1034,9 +1040,12 @@ class InterfaceConnectionFilter(django_filters.FilterSet):
method='filter_site', method='filter_site',
label='Site (slug)', label='Site (slug)',
) )
device = django_filters.CharFilter( device_id = MultiValueNumberFilter(
method='filter_device'
)
device = MultiValueCharFilter(
method='filter_device', method='filter_device',
label='Device', field_name='device__name'
) )
class Meta: class Meta:
@ -1052,11 +1061,11 @@ class InterfaceConnectionFilter(django_filters.FilterSet):
) )
def filter_device(self, queryset, name, value): def filter_device(self, queryset, name, value):
if not value.strip(): if not value:
return queryset return queryset
return queryset.filter( return queryset.filter(
Q(device__name__icontains=value) | Q(**{'{}__in'.format(name): value}) |
Q(_connected_interface__device__name__icontains=value) Q(**{'_connected_interface__{}__in'.format(name): value})
) )

View File

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

View File

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

View File

@ -3,7 +3,10 @@ from django.contrib import admin
from netbox.admin import admin_site from netbox.admin import admin_site
from utilities.forms import LaxURLField 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): def order_content_types(field):
@ -166,6 +169,36 @@ class ExportTemplateAdmin(admin.ModelAdmin):
form = ExportTemplateForm 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 # Topology maps
# #

View File

@ -52,7 +52,7 @@ def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=F
else: else:
initial = None initial = None
field = forms.NullBooleanField( field = forms.NullBooleanField(
required=cf.required, initial=initial, widget=forms.Select(choices=choices) required=cf.required, initial=initial, widget=StaticSelect2(choices=choices)
) )
# Date # 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 default_choice = cf.choices.get(value=initial).pk
except ObjectDoesNotExist: except ObjectDoesNotExist:
pass 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 # URL
elif cf.type == CF_TYPE_URL: elif cf.type == CF_TYPE_URL:

View File

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

View File

@ -46,12 +46,17 @@ def custom_links(obj):
# Add non-grouped links # Add non-grouped links
else: else:
text_rendered = render_jinja2(cl.text, context) try:
if text_rendered: text_rendered = render_jinja2(cl.text, context)
link_target = ' target="_blank"' if cl.new_window else '' if text_rendered:
template_code += LINK_BUTTON.format( link_rendered = render_jinja2(cl.url, context)
cl.url, link_target, cl.button_class, text_rendered 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 # Add grouped links to template
for group, links in group_names.items(): for group, links in group_names.items():
@ -59,11 +64,17 @@ def custom_links(obj):
links_rendered = [] links_rendered = []
for cl in links: for cl in links:
text_rendered = render_jinja2(cl.text, context) try:
if text_rendered: text_rendered = render_jinja2(cl.text, context)
link_target = ' target="_blank"' if cl.new_window else '' 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( 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: if links_rendered:
@ -71,7 +82,4 @@ def custom_links(obj):
links[0].button_class, group, ''.join(links_rendered) links[0].button_class, group, ''.join(links_rendered)
) )
# Render template return mark_safe(template_code)
rendered = render_jinja2(template_code, context)
return mark_safe(rendered)

View File

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

View File

@ -938,7 +938,8 @@ class IPAddressAssignForm(BootstrapMixin, forms.Form):
class IPAddressFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): class IPAddressFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
model = IPAddress model = IPAddress
field_order = [ 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( q = forms.CharField(
required=False, required=False,
@ -984,6 +985,13 @@ class IPAddressFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterFo
required=False, required=False,
widget=StaticSelect2Multiple() 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: "---------", placeholder: "---------",
theme: "bootstrap", theme: "bootstrap",
templateResult: colorPickerClassCopy, templateResult: colorPickerClassCopy,
templateSelection: colorPickerClassCopy templateSelection: colorPickerClassCopy,
width: "off"
}); });
// Static choice selection // Static choice selection
$('.netbox-select2-static').select2({ $('.netbox-select2-static').select2({
allowClear: true, allowClear: true,
placeholder: "---------", placeholder: "---------",
theme: "bootstrap" theme: "bootstrap",
width: "off"
}); });
// API backed selection // API backed selection
@ -120,6 +122,7 @@ $(document).ready(function() {
allowClear: true, allowClear: true,
placeholder: "---------", placeholder: "---------",
theme: "bootstrap", theme: "bootstrap",
width: "off",
ajax: { ajax: {
delay: 500, delay: 500,
@ -299,7 +302,8 @@ $(document).ready(function() {
multiple: true, multiple: true,
allowClear: true, allowClear: true,
placeholder: "Tags", placeholder: "Tags",
theme: "bootstrap",
width: "off",
ajax: { ajax: {
delay: 250, delay: 250,
url: netbox_api_path + "extras/tags/", 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. 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. :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__( def __init__(
self, 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>