diff --git a/docs/release-notes/version-2.6.md b/docs/release-notes/version-2.6.md index 2bf55d857..62d732a9b 100644 --- a/docs/release-notes/version-2.6.md +++ b/docs/release-notes/version-2.6.md @@ -1,3 +1,19 @@ +# v2.6.12 (FUTURE) + +## Enhancements + +* [#2050](https://github.com/netbox-community/netbox/issues/2050) - Preview image attachments when hovering the link +* [#3187](https://github.com/netbox-community/netbox/issues/3187) - Add rack selection field to rack elevations + +## Bug Fixes + +* [#3589](https://github.com/netbox-community/netbox/issues/3589) - Fix validation on tagged VLANs of an interface +* [#3853](https://github.com/netbox-community/netbox/issues/3853) - Fix device role link on config context view +* [#3856](https://github.com/netbox-community/netbox/issues/3856) - Allow filtering VM interfaces by multiple MAC addresses +* [#3862](https://github.com/netbox-community/netbox/issues/3862) - Allow filtering device components by multiple device names + +--- + # v2.6.11 (2020-01-03) ## Bug Fixes diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index 638313507..ffed7fa3d 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -646,7 +646,8 @@ class DeviceComponentFilterSet(django_filters.FilterSet): queryset=Device.objects.all(), label='Device (ID)', ) - device = django_filters.ModelChoiceFilter( + device = django_filters.ModelMultipleChoiceFilter( + field_name='device__name', queryset=Device.objects.all(), to_field_name='name', label='Device (name)', diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index a5ce2811c..dbb9cff15 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -74,6 +74,17 @@ class InterfaceCommonForm: elif self.cleaned_data['mode'] == IFACE_MODE_TAGGED_ALL: self.cleaned_data['tagged_vlans'] = [] + # Validate tagged VLANs; must be a global VLAN or in the same site + elif self.cleaned_data['mode'] == IFACE_MODE_TAGGED: + valid_sites = [None, self.cleaned_data['device'].site] + invalid_vlans = [str(v) for v in tagged_vlans if v.site not in valid_sites] + + if invalid_vlans: + raise forms.ValidationError({ + 'tagged_vlans': "The tagged VLANs ({}) must belong to the same site as the interface's parent " + "device/VM, or they must be global".format(', '.join(invalid_vlans)) + }) + class BulkRenameForm(forms.Form): """ @@ -703,6 +714,34 @@ class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): ) +# +# Rack elevations +# + +class RackElevationFilterForm(RackFilterForm): + field_order = ['q', 'region', 'site', 'group_id', 'id', 'status', 'role', 'tenant_group', 'tenant'] + id = ChainedModelChoiceField( + queryset=Rack.objects.all(), + label='Rack', + chains=( + ('site', 'site'), + ('group_id', 'group_id'), + ), + required=False, + widget=APISelectMultiple( + api_url='/api/dcim/racks/', + display_field='display_name', + ) + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Filter the rack field based on the site and group + self.fields['site'].widget.add_filter_for('id', 'site') + self.fields['group_id'].widget.add_filter_for('id', 'group_id') + + # # Rack reservations # @@ -2250,36 +2289,6 @@ class InterfaceForm(InterfaceCommonForm, BootstrapMixin, forms.ModelForm): device__in=[self.instance.device, self.instance.device.get_vc_master()], type=IFACE_TYPE_LAG ) - # Limit VLan choices to those in: global vlans, global groups, the current site's group, the current site - vlan_choices = [] - global_vlans = VLAN.objects.filter(site=None, group=None) - vlan_choices.append( - ('Global', [(vlan.pk, vlan) for vlan in global_vlans]) - ) - for group in VLANGroup.objects.filter(site=None): - global_group_vlans = VLAN.objects.filter(group=group) - vlan_choices.append( - (group.name, [(vlan.pk, vlan) for vlan in global_group_vlans]) - ) - - site = getattr(self.instance.parent, 'site', None) - if site is not None: - - # Add non-grouped site VLANs - site_vlans = VLAN.objects.filter(site=site, group=None) - vlan_choices.append((site.name, [(vlan.pk, vlan) for vlan in site_vlans])) - - # Add grouped site VLANs - for group in VLANGroup.objects.filter(site=site): - site_group_vlans = VLAN.objects.filter(group=group) - vlan_choices.append(( - '{} / {}'.format(group.site.name, group.name), - [(vlan.pk, vlan) for vlan in site_group_vlans] - )) - - self.fields['untagged_vlan'].choices = [(None, '---------')] + vlan_choices - self.fields['tagged_vlans'].choices = vlan_choices - class InterfaceCreateForm(InterfaceCommonForm, ComponentForm, forms.Form): name_pattern = ExpandableNameField( @@ -2360,36 +2369,6 @@ class InterfaceCreateForm(InterfaceCommonForm, ComponentForm, forms.Form): else: self.fields['lag'].queryset = Interface.objects.none() - # Limit VLan choices to those in: global vlans, global groups, the current site's group, the current site - vlan_choices = [] - global_vlans = VLAN.objects.filter(site=None, group=None) - vlan_choices.append( - ('Global', [(vlan.pk, vlan) for vlan in global_vlans]) - ) - for group in VLANGroup.objects.filter(site=None): - global_group_vlans = VLAN.objects.filter(group=group) - vlan_choices.append( - (group.name, [(vlan.pk, vlan) for vlan in global_group_vlans]) - ) - - site = getattr(self.parent, 'site', None) - if site is not None: - - # Add non-grouped site VLANs - site_vlans = VLAN.objects.filter(site=site, group=None) - vlan_choices.append((site.name, [(vlan.pk, vlan) for vlan in site_vlans])) - - # Add grouped site VLANs - for group in VLANGroup.objects.filter(site=site): - site_group_vlans = VLAN.objects.filter(group=group) - vlan_choices.append(( - '{} / {}'.format(group.site.name, group.name), - [(vlan.pk, vlan) for vlan in site_group_vlans] - )) - - self.fields['untagged_vlan'].choices = [(None, '---------')] + vlan_choices - self.fields['tagged_vlans'].choices = vlan_choices - class InterfaceBulkEditForm(InterfaceCommonForm, BootstrapMixin, AddRemoveTagsForm, BulkEditForm): pk = forms.ModelMultipleChoiceField( @@ -2472,36 +2451,6 @@ class InterfaceBulkEditForm(InterfaceCommonForm, BootstrapMixin, AddRemoveTagsFo else: self.fields['lag'].choices = [] - # Limit VLan choices to those in: global vlans, global groups, the current site's group, the current site - vlan_choices = [] - global_vlans = VLAN.objects.filter(site=None, group=None) - vlan_choices.append( - ('Global', [(vlan.pk, vlan) for vlan in global_vlans]) - ) - for group in VLANGroup.objects.filter(site=None): - global_group_vlans = VLAN.objects.filter(group=group) - vlan_choices.append( - (group.name, [(vlan.pk, vlan) for vlan in global_group_vlans]) - ) - if self.parent_obj is not None: - site = getattr(self.parent_obj, 'site', None) - if site is not None: - - # Add non-grouped site VLANs - site_vlans = VLAN.objects.filter(site=site, group=None) - vlan_choices.append((site.name, [(vlan.pk, vlan) for vlan in site_vlans])) - - # Add grouped site VLANs - for group in VLANGroup.objects.filter(site=site): - site_group_vlans = VLAN.objects.filter(group=group) - vlan_choices.append(( - '{} / {}'.format(group.site.name, group.name), - [(vlan.pk, vlan) for vlan in site_group_vlans] - )) - - self.fields['untagged_vlan'].choices = [(None, '---------')] + vlan_choices - self.fields['tagged_vlans'].choices = vlan_choices - class InterfaceBulkRenameForm(BulkRenameForm): pk = forms.ModelMultipleChoiceField( diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 959e1043e..2d98515cf 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -388,7 +388,7 @@ class RackElevationListView(PermissionRequiredMixin, View): 'page': page, 'total_count': total_count, 'face_id': face_id, - 'filter_form': forms.RackFilterForm(request.GET), + 'filter_form': forms.RackElevationFilterForm(request.GET), }) diff --git a/netbox/project-static/js/forms.js b/netbox/project-static/js/forms.js index 55f9afbd5..43c722ae5 100644 --- a/netbox/project-static/js/forms.js +++ b/netbox/project-static/js/forms.js @@ -398,4 +398,43 @@ $(document).ready(function() { // Account for the header height when hash-scrolling window.addEventListener('load', headerOffsetScroll); window.addEventListener('hashchange', headerOffsetScroll); + + // Offset between the preview window and the window edges + const IMAGE_PREVIEW_OFFSET_X = 20 + const IMAGE_PREVIEW_OFFSET_Y = 10 + + // Preview an image attachment when the link is hovered over + $('a.image-preview').on('mouseover', function(e) { + // Twice the offset to account for all sides of the picture + var maxWidth = window.innerWidth - (e.clientX + (IMAGE_PREVIEW_OFFSET_X * 2)); + var maxHeight = window.innerHeight - (e.clientY + (IMAGE_PREVIEW_OFFSET_Y * 2)); + var img = $('').attr('id', 'image-preview-window').css({ + display: 'none', + position: 'absolute', + maxWidth: maxWidth + 'px', + maxHeight: maxHeight + 'px', + left: e.pageX + IMAGE_PREVIEW_OFFSET_X + 'px', + top: e.pageY + IMAGE_PREVIEW_OFFSET_Y + 'px', + boxShadow: '0 0px 12px 3px rgba(0, 0, 0, 0.4)', + }); + + // Remove any existing preview windows and add the current one + $('#image-preview-window').remove(); + $('body').append(img); + + // Once loaded, show the preview if the image is indeed an image + img.on('load', function(e) { + if (e.target.complete && e.target.naturalWidth) { + $('#image-preview-window').fadeIn('fast'); + } + }); + + // Begin loading + img.attr('src', e.target.href); + }); + + // Fade the image out; it will be deleted when another one is previewed + $('a.image-preview').on('mouseout', function() { + $('#image-preview-window').fadeOut('fast') + }); }); diff --git a/netbox/templates/extras/configcontext.html b/netbox/templates/extras/configcontext.html index 3631122c3..f1ff4fa1f 100644 --- a/netbox/templates/extras/configcontext.html +++ b/netbox/templates/extras/configcontext.html @@ -112,7 +112,7 @@ {% if configcontext.roles.all %} {% else %} diff --git a/netbox/templates/inc/image_attachments.html b/netbox/templates/inc/image_attachments.html index 1487b5349..2fee4dc78 100644 --- a/netbox/templates/inc/image_attachments.html +++ b/netbox/templates/inc/image_attachments.html @@ -10,7 +10,7 @@ - {{ attachment }} + {{ attachment }} {{ attachment.size|filesizeformat }} {{ attachment.created }} diff --git a/netbox/virtualization/filters.py b/netbox/virtualization/filters.py index 6c75c78fc..8ce97f9e2 100644 --- a/netbox/virtualization/filters.py +++ b/netbox/virtualization/filters.py @@ -208,8 +208,7 @@ class InterfaceFilter(django_filters.FilterSet): to_field_name='name', label='Virtual machine', ) - mac_address = django_filters.CharFilter( - method='_mac_address', + mac_address = MultiValueMACAddressFilter( label='MAC address', ) @@ -217,16 +216,6 @@ class InterfaceFilter(django_filters.FilterSet): model = Interface fields = ['id', 'name', 'enabled', 'mtu'] - def _mac_address(self, queryset, name, value): - value = value.strip() - if not value: - return queryset - try: - mac = EUI(value.strip()) - return queryset.filter(mac_address=mac) - except AddrFormatError: - return queryset.none() - def search(self, queryset, name, value): if not value.strip(): return queryset