Merge remote-tracking branch 'netbox-community/develop' into 3589-interface-tagged-vlans

This commit is contained in:
Saria Hajjar 2020-01-03 19:19:12 +00:00
commit fa55571503
19 changed files with 533 additions and 99 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

@ -1,10 +1,25 @@
# v2.6.10 (FUTURE) # v2.6.11 (2020-01-03)
## Bug Fixes
* [#3831](https://github.com/netbox-community/netbox/issues/3831) - Fix API-driven filter field rendering (#3812 regression)
* [#3833](https://github.com/netbox-community/netbox/issues/3833) - Add missing region filters for multiple objects
---
# v2.6.10 (2020-01-02)
## 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
@ -15,6 +30,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

@ -18,6 +18,17 @@ class ProviderFilter(CustomFieldFilterSet, CreatedUpdatedFilterSet):
method='search', method='search',
label='Search', label='Search',
) )
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='circuits__terminations__site__region__in',
label='Region (ID)',
)
region = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='circuits__terminations__site__region__in',
to_field_name='slug',
label='Region (slug)',
)
site_id = django_filters.ModelMultipleChoiceFilter( site_id = django_filters.ModelMultipleChoiceFilter(
field_name='circuits__terminations__site', field_name='circuits__terminations__site',
queryset=Site.objects.all(), queryset=Site.objects.all(),

View File

@ -104,6 +104,18 @@ class ProviderFilterForm(BootstrapMixin, CustomFieldFilterForm):
required=False, required=False,
label='Search' label='Search'
) )
region = FilterChoiceField(
queryset=Region.objects.all(),
to_field_name='slug',
required=False,
widget=APISelectMultiple(
api_url="/api/dcim/regions/",
value_field="slug",
filter_for={
'site': 'region'
}
)
)
site = FilterChoiceField( site = FilterChoiceField(
queryset=Site.objects.all(), queryset=Site.objects.all(),
to_field_name='slug', to_field_name='slug',
@ -302,6 +314,9 @@ class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm
widget=APISelectMultiple( widget=APISelectMultiple(
api_url="/api/dcim/regions/", api_url="/api/dcim/regions/",
value_field="slug", value_field="slug",
filter_for={
'site': 'region'
}
) )
) )
site = FilterChoiceField( site = FilterChoiceField(

View File

@ -93,6 +93,17 @@ class SiteFilter(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet
class RackGroupFilter(NameSlugSearchFilterSet): class RackGroupFilter(NameSlugSearchFilterSet):
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='site__region__in',
label='Region (ID)',
)
region = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='site__region__in',
to_field_name='slug',
label='Region (slug)',
)
site_id = django_filters.ModelMultipleChoiceFilter( site_id = django_filters.ModelMultipleChoiceFilter(
queryset=Site.objects.all(), queryset=Site.objects.all(),
label='Site (ID)', label='Site (ID)',
@ -125,6 +136,17 @@ class RackFilter(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet
method='search', method='search',
label='Search', label='Search',
) )
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='site__region__in',
label='Region (ID)',
)
region = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='site__region__in',
to_field_name='slug',
label='Region (slug)',
)
site_id = django_filters.ModelMultipleChoiceFilter( site_id = django_filters.ModelMultipleChoiceFilter(
queryset=Site.objects.all(), queryset=Site.objects.all(),
label='Site (ID)', label='Site (ID)',
@ -831,6 +853,28 @@ class InventoryItemFilter(DeviceComponentFilterSet):
method='search', method='search',
label='Search', label='Search',
) )
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='device__site__region__in',
label='Region (ID)',
)
region = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='device__site__region__in',
to_field_name='slug',
label='Region (slug)',
)
site_id = django_filters.ModelMultipleChoiceFilter(
field_name='device__site',
queryset=Site.objects.all(),
label='Site (ID)',
)
site = django_filters.ModelMultipleChoiceFilter(
field_name='device__site__slug',
queryset=Site.objects.all(),
to_field_name='slug',
label='Site name (slug)',
)
device_id = django_filters.ModelChoiceFilter( device_id = django_filters.ModelChoiceFilter(
queryset=Device.objects.all(), queryset=Device.objects.all(),
label='Device (ID)', label='Device (ID)',
@ -880,6 +924,17 @@ class VirtualChassisFilter(django_filters.FilterSet):
method='search', method='search',
label='Search', label='Search',
) )
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='master__site__region__in',
label='Region (ID)',
)
region = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='master__site__region__in',
to_field_name='slug',
label='Region (slug)',
)
site_id = django_filters.ModelMultipleChoiceFilter( site_id = django_filters.ModelMultipleChoiceFilter(
field_name='master__site', field_name='master__site',
queryset=Site.objects.all(), queryset=Site.objects.all(),
@ -935,7 +990,7 @@ class CableFilter(django_filters.FilterSet):
device_id = MultiValueNumberFilter( device_id = MultiValueNumberFilter(
method='filter_device' method='filter_device'
) )
device = MultiValueNumberFilter( device = MultiValueCharFilter(
method='filter_device', method='filter_device',
field_name='device__name' field_name='device__name'
) )
@ -978,9 +1033,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 +1051,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 +1064,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 +1082,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 +1095,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 +1116,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})
) )
@ -1069,6 +1133,17 @@ class PowerPanelFilter(django_filters.FilterSet):
method='search', method='search',
label='Search', label='Search',
) )
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='site__region__in',
label='Region (ID)',
)
region = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='site__region__in',
to_field_name='slug',
label='Region (slug)',
)
site_id = django_filters.ModelMultipleChoiceFilter( site_id = django_filters.ModelMultipleChoiceFilter(
queryset=Site.objects.all(), queryset=Site.objects.all(),
label='Site (ID)', label='Site (ID)',
@ -1107,6 +1182,17 @@ class PowerFeedFilter(CustomFieldFilterSet, CreatedUpdatedFilterSet):
method='search', method='search',
label='Search', label='Search',
) )
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='power_panel__site__region__in',
label='Region (ID)',
)
region = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='power_panel__site__region__in',
to_field_name='slug',
label='Region (slug)',
)
site_id = django_filters.ModelMultipleChoiceFilter( site_id = django_filters.ModelMultipleChoiceFilter(
field_name='power_panel__site', field_name='power_panel__site',
queryset=Site.objects.all(), queryset=Site.objects.all(),

View File

@ -375,6 +375,18 @@ class RackGroupCSVForm(forms.ModelForm):
class RackGroupFilterForm(BootstrapMixin, forms.Form): class RackGroupFilterForm(BootstrapMixin, forms.Form):
region = FilterChoiceField(
queryset=Region.objects.all(),
to_field_name='slug',
required=False,
widget=APISelectMultiple(
api_url="/api/dcim/regions/",
value_field="slug",
filter_for={
'site': 'region'
}
)
)
site = FilterChoiceField( site = FilterChoiceField(
queryset=Site.objects.all(), queryset=Site.objects.all(),
to_field_name='slug', to_field_name='slug',
@ -646,11 +658,23 @@ class RackBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor
class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
model = Rack model = Rack
field_order = ['q', 'site', 'group_id', 'status', 'role', 'tenant_group', 'tenant'] field_order = ['q', 'region', 'site', 'group_id', 'status', 'role', 'tenant_group', 'tenant']
q = forms.CharField( q = forms.CharField(
required=False, required=False,
label='Search' label='Search'
) )
region = FilterChoiceField(
queryset=Region.objects.all(),
to_field_name='slug',
required=False,
widget=APISelectMultiple(
api_url="/api/dcim/regions/",
value_field="slug",
filter_for={
'site': 'region'
}
)
)
site = FilterChoiceField( site = FilterChoiceField(
queryset=Site.objects.all(), queryset=Site.objects.all(),
to_field_name='slug', to_field_name='slug',
@ -662,16 +686,15 @@ class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
} }
) )
) )
group_id = ChainedModelChoiceField( group_id = FilterChoiceField(
label='Rack group', queryset=RackGroup.objects.prefetch_related(
queryset=RackGroup.objects.prefetch_related('site'), 'site'
chains=(
('site', 'site'),
), ),
required=False, label='Rack group',
null_label='-- None --',
widget=APISelectMultiple( widget=APISelectMultiple(
api_url="/api/dcim/rack-groups/", api_url="/api/dcim/rack-groups/",
null_option=True, null_option=True
) )
) )
status = forms.MultipleChoiceField( status = forms.MultipleChoiceField(
@ -3122,9 +3145,13 @@ class CableFilterForm(BootstrapMixin, forms.Form):
required=False, required=False,
widget=ColorSelect() widget=ColorSelect()
) )
device = forms.CharField( device_id = FilterChoiceField(
queryset=Device.objects.all(),
required=False, required=False,
label='Device name' label='Device',
widget=APISelectMultiple(
api_url='/api/dcim/devices/',
)
) )
@ -3189,38 +3216,59 @@ class DeviceBayBulkRenameForm(BulkRenameForm):
# #
class ConsoleConnectionFilterForm(BootstrapMixin, forms.Form): class ConsoleConnectionFilterForm(BootstrapMixin, forms.Form):
site = forms.ModelChoiceField( site = FilterChoiceField(
queryset=Site.objects.all(), queryset=Site.objects.all(),
required=False, to_field_name='slug',
to_field_name='slug' widget=APISelectMultiple(
api_url="/api/dcim/sites/",
value_field="slug",
)
) )
device = forms.CharField( device_id = FilterChoiceField(
queryset=Device.objects.all(),
required=False, required=False,
label='Device name' label='Device',
widget=APISelectMultiple(
api_url='/api/dcim/devices/',
)
) )
class PowerConnectionFilterForm(BootstrapMixin, forms.Form): class PowerConnectionFilterForm(BootstrapMixin, forms.Form):
site = forms.ModelChoiceField( site = FilterChoiceField(
queryset=Site.objects.all(), queryset=Site.objects.all(),
required=False, to_field_name='slug',
to_field_name='slug' widget=APISelectMultiple(
api_url="/api/dcim/sites/",
value_field="slug",
)
) )
device = forms.CharField( device_id = FilterChoiceField(
queryset=Device.objects.all(),
required=False, required=False,
label='Device name' label='Device',
widget=APISelectMultiple(
api_url='/api/dcim/devices/',
)
) )
class InterfaceConnectionFilterForm(BootstrapMixin, forms.Form): class InterfaceConnectionFilterForm(BootstrapMixin, forms.Form):
site = forms.ModelChoiceField( site = FilterChoiceField(
queryset=Site.objects.all(), queryset=Site.objects.all(),
required=False, to_field_name='slug',
to_field_name='slug' widget=APISelectMultiple(
api_url="/api/dcim/sites/",
value_field="slug",
)
) )
device = forms.CharField( device_id = FilterChoiceField(
queryset=Device.objects.all(),
required=False, required=False,
label='Device name' label='Device',
widget=APISelectMultiple(
api_url='/api/dcim/devices/',
)
) )
@ -3236,9 +3284,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/"
) )
@ -3274,9 +3325,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,
@ -3300,18 +3361,48 @@ class InventoryItemFilterForm(BootstrapMixin, forms.Form):
required=False, required=False,
label='Search' label='Search'
) )
device = forms.CharField( region = FilterChoiceField(
queryset=Region.objects.all(),
to_field_name='slug',
required=False, required=False,
label='Device name' widget=APISelectMultiple(
api_url="/api/dcim/regions/",
value_field="slug",
filter_for={
'site': 'region'
}
)
)
site = FilterChoiceField(
queryset=Site.objects.all(),
to_field_name='slug',
widget=APISelectMultiple(
api_url="/api/dcim/sites/",
value_field="slug",
filter_for={
'device_id': 'site'
}
)
)
device_id = FilterChoiceField(
queryset=Device.objects.all(),
required=False,
label='Device',
widget=APISelect(
api_url='/api/dcim/devices/',
)
) )
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
) )
) )
@ -3458,6 +3549,18 @@ class VirtualChassisFilterForm(BootstrapMixin, CustomFieldFilterForm):
required=False, required=False,
label='Search' label='Search'
) )
region = FilterChoiceField(
queryset=Region.objects.all(),
to_field_name='slug',
required=False,
widget=APISelectMultiple(
api_url="/api/dcim/regions/",
value_field="slug",
filter_for={
'site': 'region'
}
)
)
site = FilterChoiceField( site = FilterChoiceField(
queryset=Site.objects.all(), queryset=Site.objects.all(),
to_field_name='slug', to_field_name='slug',
@ -3563,6 +3666,18 @@ class PowerPanelFilterForm(BootstrapMixin, CustomFieldFilterForm):
required=False, required=False,
label='Search' label='Search'
) )
region = FilterChoiceField(
queryset=Region.objects.all(),
to_field_name='slug',
required=False,
widget=APISelectMultiple(
api_url="/api/dcim/regions/",
value_field="slug",
filter_for={
'site': 'region'
}
)
)
site = FilterChoiceField( site = FilterChoiceField(
queryset=Site.objects.all(), queryset=Site.objects.all(),
to_field_name='slug', to_field_name='slug',
@ -3783,6 +3898,18 @@ class PowerFeedFilterForm(BootstrapMixin, CustomFieldFilterForm):
required=False, required=False,
label='Search' label='Search'
) )
region = FilterChoiceField(
queryset=Region.objects.all(),
to_field_name='slug',
required=False,
widget=APISelectMultiple(
api_url="/api/dcim/regions/",
value_field="slug",
filter_for={
'site': 'region'
}
)
)
site = FilterChoiceField( site = FilterChoiceField(
queryset=Site.objects.all(), queryset=Site.objects.all(),
to_field_name='slug', to_field_name='slug',

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

@ -4,10 +4,10 @@ from django.core.exceptions import ValidationError
from django.db.models import Q from django.db.models import Q
from netaddr.core import AddrFormatError from netaddr.core import AddrFormatError
from dcim.models import Site, Device, Interface from dcim.models import Device, Interface, Region, Site
from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet
from tenancy.filtersets import TenancyFilterSet from tenancy.filtersets import TenancyFilterSet
from utilities.filters import NameSlugSearchFilterSet, NumericInFilter, TagFilter from utilities.filters import NameSlugSearchFilterSet, NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter
from virtualization.models import VirtualMachine from virtualization.models import VirtualMachine
from .constants import * from .constants import *
from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
@ -149,6 +149,17 @@ class PrefixFilter(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterS
to_field_name='rd', to_field_name='rd',
label='VRF (RD)', label='VRF (RD)',
) )
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='site__region__in',
label='Region (ID)',
)
region = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='site__region__in',
to_field_name='slug',
label='Region (slug)',
)
site_id = django_filters.ModelMultipleChoiceFilter( site_id = django_filters.ModelMultipleChoiceFilter(
queryset=Site.objects.all(), queryset=Site.objects.all(),
label='Site (ID)', label='Site (ID)',
@ -309,6 +320,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,8 +381,22 @@ 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):
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='site__region__in',
label='Region (ID)',
)
region = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='site__region__in',
to_field_name='slug',
label='Region (slug)',
)
site_id = django_filters.ModelMultipleChoiceFilter( site_id = django_filters.ModelMultipleChoiceFilter(
queryset=Site.objects.all(), queryset=Site.objects.all(),
label='Site (ID)', label='Site (ID)',
@ -393,6 +422,17 @@ class VLANFilter(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet
method='search', method='search',
label='Search', label='Search',
) )
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='site__region__in',
label='Region (ID)',
)
region = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='site__region__in',
to_field_name='slug',
label='Region (slug)',
)
site_id = django_filters.ModelMultipleChoiceFilter( site_id = django_filters.ModelMultipleChoiceFilter(
queryset=Site.objects.all(), queryset=Site.objects.all(),
label='Site (ID)', label='Site (ID)',

View File

@ -3,7 +3,7 @@ from django.core.exceptions import MultipleObjectsReturned
from django.core.validators import MaxValueValidator, MinValueValidator from django.core.validators import MaxValueValidator, MinValueValidator
from taggit.forms import TagField from taggit.forms import TagField
from dcim.models import Site, Rack, Device, Interface from dcim.models import Device, Interface, Rack, Region, Site
from extras.forms import AddRemoveTagsForm, CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm from extras.forms import AddRemoveTagsForm, CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
from tenancy.forms import TenancyFilterForm, TenancyForm from tenancy.forms import TenancyFilterForm, TenancyForm
from tenancy.models import Tenant from tenancy.models import Tenant
@ -492,8 +492,8 @@ class PrefixBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF
class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
model = Prefix model = Prefix
field_order = [ field_order = [
'q', 'within_include', 'family', 'mask_length', 'vrf_id', 'status', 'site', 'role', 'tenant_group', 'tenant', 'q', 'within_include', 'family', 'mask_length', 'vrf_id', 'status', 'region', 'site', 'role', 'tenant_group',
'is_pool', 'expand', 'tenant', 'is_pool', 'expand',
] ]
q = forms.CharField( q = forms.CharField(
required=False, required=False,
@ -534,6 +534,18 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm)
required=False, required=False,
widget=StaticSelect2Multiple() widget=StaticSelect2Multiple()
) )
region = FilterChoiceField(
queryset=Region.objects.all(),
to_field_name='slug',
required=False,
widget=APISelectMultiple(
api_url="/api/dcim/regions/",
value_field="slug",
filter_for={
'site': 'region'
}
)
)
site = FilterChoiceField( site = FilterChoiceField(
queryset=Site.objects.all(), queryset=Site.objects.all(),
to_field_name='slug', to_field_name='slug',
@ -938,7 +950,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 +997,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
)
)
# #
@ -1026,6 +1046,18 @@ class VLANGroupCSVForm(forms.ModelForm):
class VLANGroupFilterForm(BootstrapMixin, forms.Form): class VLANGroupFilterForm(BootstrapMixin, forms.Form):
region = FilterChoiceField(
queryset=Region.objects.all(),
to_field_name='slug',
required=False,
widget=APISelectMultiple(
api_url="/api/dcim/regions/",
value_field="slug",
filter_for={
'site': 'region',
}
)
)
site = FilterChoiceField( site = FilterChoiceField(
queryset=Site.objects.all(), queryset=Site.objects.all(),
to_field_name='slug', to_field_name='slug',
@ -1207,11 +1239,24 @@ class VLANBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor
class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
model = VLAN model = VLAN
field_order = ['q', 'site', 'group_id', 'status', 'role', 'tenant_group', 'tenant'] field_order = ['q', 'region', 'site', 'group_id', 'status', 'role', 'tenant_group', 'tenant']
q = forms.CharField( q = forms.CharField(
required=False, required=False,
label='Search' label='Search'
) )
region = FilterChoiceField(
queryset=Region.objects.all(),
to_field_name='slug',
required=False,
widget=APISelectMultiple(
api_url="/api/dcim/regions/",
value_field="slug",
filter_for={
'site': 'region',
'group_id': 'region'
}
)
)
site = FilterChoiceField( site = FilterChoiceField(
queryset=Site.objects.all(), queryset=Site.objects.all(),
to_field_name='slug', to_field_name='slug',

View File

@ -12,7 +12,7 @@ from django.core.exceptions import ImproperlyConfigured
# Environment setup # Environment setup
# #
VERSION = '2.6.10-dev' VERSION = '2.6.12-dev'
# Hostname # Hostname
HOSTNAME = platform.node() HOSTNAME = platform.node()

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,9 @@
<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 option in group_choices %}
{% if option.attrs.selected or option.value == "null" %}{% include option.template_name with widget=option %}{% endif %}
{% endfor %}
{% if group_name %}</optgroup>{% endif %}
{% endfor %}
</select>

View File

@ -36,6 +36,27 @@ class ClusterFilter(CustomFieldFilterSet, CreatedUpdatedFilterSet):
method='search', method='search',
label='Search', label='Search',
) )
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='site__region__in',
label='Region (ID)',
)
region = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='site__region__in',
to_field_name='slug',
label='Region (slug)',
)
site_id = django_filters.ModelMultipleChoiceFilter(
queryset=Site.objects.all(),
label='Site (ID)',
)
site = django_filters.ModelMultipleChoiceFilter(
field_name='site__slug',
queryset=Site.objects.all(),
to_field_name='slug',
label='Site (slug)',
)
group_id = django_filters.ModelMultipleChoiceFilter( group_id = django_filters.ModelMultipleChoiceFilter(
queryset=ClusterGroup.objects.all(), queryset=ClusterGroup.objects.all(),
label='Parent group (ID)', label='Parent group (ID)',
@ -56,16 +77,6 @@ class ClusterFilter(CustomFieldFilterSet, CreatedUpdatedFilterSet):
to_field_name='slug', to_field_name='slug',
label='Cluster type (slug)', label='Cluster type (slug)',
) )
site_id = django_filters.ModelMultipleChoiceFilter(
queryset=Site.objects.all(),
label='Site (ID)',
)
site = django_filters.ModelMultipleChoiceFilter(
field_name='site__slug',
queryset=Site.objects.all(),
to_field_name='slug',
label='Site (slug)',
)
tag = TagFilter() tag = TagFilter()
class Meta: class Meta:

View File

@ -173,6 +173,29 @@ class ClusterBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEdit
class ClusterFilterForm(BootstrapMixin, CustomFieldFilterForm): class ClusterFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = Cluster model = Cluster
q = forms.CharField(required=False, label='Search') q = forms.CharField(required=False, label='Search')
region = FilterChoiceField(
queryset=Region.objects.all(),
to_field_name='slug',
required=False,
widget=APISelectMultiple(
api_url="/api/dcim/regions/",
value_field="slug",
filter_for={
'site': 'region'
}
)
)
site = FilterChoiceField(
queryset=Site.objects.all(),
to_field_name='slug',
null_label='-- None --',
required=False,
widget=APISelectMultiple(
api_url="/api/dcim/sites/",
value_field='slug',
null_option=True,
)
)
type = FilterChoiceField( type = FilterChoiceField(
queryset=ClusterType.objects.all(), queryset=ClusterType.objects.all(),
to_field_name='slug', to_field_name='slug',
@ -193,17 +216,6 @@ class ClusterFilterForm(BootstrapMixin, CustomFieldFilterForm):
null_option=True, null_option=True,
) )
) )
site = FilterChoiceField(
queryset=Site.objects.all(),
to_field_name='slug',
null_label='-- None --',
required=False,
widget=APISelectMultiple(
api_url="/api/dcim/sites/",
value_field='slug',
null_option=True,
)
)
class ClusterAddDevicesForm(BootstrapMixin, ChainedFieldsMixin, forms.Form): class ClusterAddDevicesForm(BootstrapMixin, ChainedFieldsMixin, forms.Form):
@ -563,7 +575,9 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil
widget=APISelectMultiple( widget=APISelectMultiple(
api_url='/api/dcim/regions/', api_url='/api/dcim/regions/',
value_field="slug", value_field="slug",
null_option=True, filter_for={
'site': 'region'
}
) )
) )
site = FilterChoiceField( site = FilterChoiceField(