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