Просмотр исходного кода

Merge pull request #5100 from netbox-community/develop

Release v2.9.3
Jeremy Stretch 5 лет назад
Родитель
Сommit
d0ac4332ab

+ 1 - 1
docs/installation/3-netbox.md

@@ -25,7 +25,7 @@ Begin by installing all system packages required by NetBox and its dependencies.
 Before continuing with either platform, update pip (Python's package management tool) to its latest release:
 
 ```no-highlight
-# pip install --upgrade pip
+# pip3 install --upgrade pip
 ```
 
 ## Download NetBox

+ 3 - 0
docs/plugins/development.md

@@ -328,6 +328,9 @@ A `PluginMenuButton` has the following attributes:
 * `color` - One of the choices provided by `ButtonColorChoices` (optional)
 * `permissions` - A list of permissions required to display this button (optional)
 
+!!! note
+    Any buttons associated within a menu item will be shown only if the user has permission to view the link, regardless of what permissions are set on the buttons.
+
 ## Extending Core Templates
 
 Plugins can inject custom content into certain areas of the detail views of applicable models. This is accomplished by subclassing `PluginTemplateExtension`, designating a particular NetBox model, and defining the desired methods to render custom content. Four methods are available:

+ 25 - 0
docs/release-notes/version-2.9.md

@@ -1,5 +1,30 @@
 # NetBox v2.9
 
+## v2.9.3 (2020-09-04)
+
+### Enhancements
+
+* [#4977](https://github.com/netbox-community/netbox/issues/4977) - Redirect authenticated users from login view
+* [#5048](https://github.com/netbox-community/netbox/issues/5048) - Show the device/VM name when editing a component
+* [#5072](https://github.com/netbox-community/netbox/issues/5072) - Add REST API filters for image attachments
+* [#5080](https://github.com/netbox-community/netbox/issues/5080) - Add 8P6C, 8P4C, 8P2C port types
+
+### Bug Fixes
+
+* [#5046](https://github.com/netbox-community/netbox/issues/5046) - Disabled plugin menu items are no longer clickable
+* [#5063](https://github.com/netbox-community/netbox/issues/5063) - Fix "add device" link in rack elevations for opposite side of half-depth devices
+* [#5074](https://github.com/netbox-community/netbox/issues/5074) - Fix inclusion of VC member interfaces when viewing VC master
+* [#5078](https://github.com/netbox-community/netbox/issues/5078) - Fix assignment of existing IP addresses to interfaces via web UI
+* [#5081](https://github.com/netbox-community/netbox/issues/5081) - Fix exception during webhook processing with custom select field
+* [#5085](https://github.com/netbox-community/netbox/issues/5085) - Fix ordering by assignment in IP addresses table
+* [#5087](https://github.com/netbox-community/netbox/issues/5087) - Restore label field when editing console server ports, power ports, and power outlets
+* [#5089](https://github.com/netbox-community/netbox/issues/5089) - Redirect to device view after editing component
+* [#5090](https://github.com/netbox-community/netbox/issues/5090) - Fix status display for console/power/interface connections
+* [#5091](https://github.com/netbox-community/netbox/issues/5091) - Avoid KeyError when handling invalid table preferences
+* [#5095](https://github.com/netbox-community/netbox/issues/5095) - Show assigned prefixes in VLANs list
+
+---
+
 ## v2.9.2 (2020-08-27)
 
 ### Enhancements

+ 6 - 0
netbox/dcim/choices.py

@@ -814,6 +814,9 @@ class InterfaceModeChoices(ChoiceSet):
 class PortTypeChoices(ChoiceSet):
 
     TYPE_8P8C = '8p8c'
+    TYPE_8P6C = '8p6c'
+    TYPE_8P4C = '8p4c'
+    TYPE_8P2C = '8p2c'
     TYPE_110_PUNCH = '110-punch'
     TYPE_BNC = 'bnc'
     TYPE_MRJ21 = 'mrj21'
@@ -833,6 +836,9 @@ class PortTypeChoices(ChoiceSet):
             'Copper',
             (
                 (TYPE_8P8C, '8P8C'),
+                (TYPE_8P6C, '8P6C'),
+                (TYPE_8P4C, '8P4C'),
+                (TYPE_8P2C, '8P2C'),
                 (TYPE_110_PUNCH, '110 Punch'),
                 (TYPE_BNC, 'BNC'),
                 (TYPE_MRJ21, 'MRJ21'),

+ 1 - 1
netbox/dcim/elevations.py

@@ -149,7 +149,7 @@ class RackElevationSVG:
         unit_cursor = 0
         for u in elevation:
             o = other[unit_cursor]
-            if not u['device'] and o['device']:
+            if not u['device'] and o['device'] and o['device'].device_type.is_full_depth:
                 u['device'] = o['device']
                 u['height'] = 1
             unit_cursor += u.get('height', 1)

+ 3 - 3
netbox/dcim/forms.py

@@ -2317,7 +2317,7 @@ class ConsoleServerPortForm(BootstrapMixin, forms.ModelForm):
     class Meta:
         model = ConsoleServerPort
         fields = [
-            'device', 'name', 'type', 'description', 'tags',
+            'device', 'name', 'label', 'type', 'description', 'tags',
         ]
         widgets = {
             'device': forms.HiddenInput(),
@@ -2390,7 +2390,7 @@ class PowerPortForm(BootstrapMixin, forms.ModelForm):
     class Meta:
         model = PowerPort
         fields = [
-            'device', 'name', 'type', 'maximum_draw', 'allocated_draw', 'description', 'tags',
+            'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'tags',
         ]
         widgets = {
             'device': forms.HiddenInput(),
@@ -2479,7 +2479,7 @@ class PowerOutletForm(BootstrapMixin, forms.ModelForm):
     class Meta:
         model = PowerOutlet
         fields = [
-            'device', 'name', 'type', 'power_port', 'feed_leg', 'description', 'tags',
+            'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'tags',
         ]
         widgets = {
             'device': forms.HiddenInput(),

+ 20 - 1
netbox/dcim/tables.py

@@ -152,6 +152,10 @@ INTERFACE_TAGGED_VLANS = """
 {% endfor %}
 """
 
+CONNECTION_STATUS = """
+<span class="label label-{% if record.connection_status %}success{% else %}danger{% endif %}">{{ record.get_connection_status_display }}</span>
+"""
+
 
 #
 # Regions
@@ -908,15 +912,20 @@ class ConsoleConnectionTable(BaseTable):
         verbose_name='Console Server'
     )
     connected_endpoint = tables.Column(
+        linkify=True,
         verbose_name='Port'
     )
     device = tables.Column(
         linkify=True
     )
     name = tables.Column(
+        linkify=True,
         verbose_name='Console Port'
     )
-    connection_status = BooleanColumn()
+    connection_status = tables.TemplateColumn(
+        template_code=CONNECTION_STATUS,
+        verbose_name='Status'
+    )
 
     class Meta(BaseTable.Meta):
         model = ConsolePort
@@ -933,14 +942,20 @@ class PowerConnectionTable(BaseTable):
     )
     outlet = tables.Column(
         accessor=Accessor('_connected_poweroutlet'),
+        linkify=True,
         verbose_name='Outlet'
     )
     device = tables.Column(
         linkify=True
     )
     name = tables.Column(
+        linkify=True,
         verbose_name='Power Port'
     )
+    connection_status = tables.TemplateColumn(
+        template_code=CONNECTION_STATUS,
+        verbose_name='Status'
+    )
 
     class Meta(BaseTable.Meta):
         model = PowerPort
@@ -972,6 +987,10 @@ class InterfaceConnectionTable(BaseTable):
         args=[Accessor('_connected_interface__pk')],
         verbose_name='Interface B'
     )
+    connection_status = tables.TemplateColumn(
+        template_code=CONNECTION_STATUS,
+        verbose_name='Status'
+    )
 
     class Meta(BaseTable.Meta):
         model = Interface

+ 8 - 1
netbox/dcim/views.py

@@ -1033,7 +1033,7 @@ class DeviceView(ObjectView):
         )
 
         # Interfaces
-        interfaces = device.vc_interfaces.restrict(request.user, 'view').filter(device=device).prefetch_related(
+        interfaces = device.vc_interfaces.restrict(request.user, 'view').prefetch_related(
             Prefetch('ip_addresses', queryset=IPAddress.objects.restrict(request.user)),
             Prefetch('member_interfaces', queryset=Interface.objects.restrict(request.user)),
             'lag', '_connected_interface__device', '_connected_circuittermination__circuit', 'cable',
@@ -1233,6 +1233,7 @@ class ConsolePortCreateView(ComponentCreateView):
 class ConsolePortEditView(ObjectEditView):
     queryset = ConsolePort.objects.all()
     model_form = forms.ConsolePortForm
+    template_name = 'dcim/device_component_edit.html'
 
 
 class ConsolePortDeleteView(ObjectDeleteView):
@@ -1292,6 +1293,7 @@ class ConsoleServerPortCreateView(ComponentCreateView):
 class ConsoleServerPortEditView(ObjectEditView):
     queryset = ConsoleServerPort.objects.all()
     model_form = forms.ConsoleServerPortForm
+    template_name = 'dcim/device_component_edit.html'
 
 
 class ConsoleServerPortDeleteView(ObjectDeleteView):
@@ -1351,6 +1353,7 @@ class PowerPortCreateView(ComponentCreateView):
 class PowerPortEditView(ObjectEditView):
     queryset = PowerPort.objects.all()
     model_form = forms.PowerPortForm
+    template_name = 'dcim/device_component_edit.html'
 
 
 class PowerPortDeleteView(ObjectDeleteView):
@@ -1410,6 +1413,7 @@ class PowerOutletCreateView(ComponentCreateView):
 class PowerOutletEditView(ObjectEditView):
     queryset = PowerOutlet.objects.all()
     model_form = forms.PowerOutletForm
+    template_name = 'dcim/device_component_edit.html'
 
 
 class PowerOutletDeleteView(ObjectDeleteView):
@@ -1561,6 +1565,7 @@ class FrontPortCreateView(ComponentCreateView):
 class FrontPortEditView(ObjectEditView):
     queryset = FrontPort.objects.all()
     model_form = forms.FrontPortForm
+    template_name = 'dcim/device_component_edit.html'
 
 
 class FrontPortDeleteView(ObjectDeleteView):
@@ -1620,6 +1625,7 @@ class RearPortCreateView(ComponentCreateView):
 class RearPortEditView(ObjectEditView):
     queryset = RearPort.objects.all()
     model_form = forms.RearPortForm
+    template_name = 'dcim/device_component_edit.html'
 
 
 class RearPortDeleteView(ObjectDeleteView):
@@ -1679,6 +1685,7 @@ class DeviceBayCreateView(ComponentCreateView):
 class DeviceBayEditView(ObjectEditView):
     queryset = DeviceBay.objects.all()
     model_form = forms.DeviceBayForm
+    template_name = 'dcim/device_component_edit.html'
 
 
 class DeviceBayDeleteView(ObjectDeleteView):

+ 1 - 1
netbox/extras/api/customfields.py

@@ -158,7 +158,7 @@ class CustomFieldModelSerializer(ValidatedModelSerializer):
         instance.custom_fields = {}
         for field in custom_fields:
             value = instance.cf.get(field.name)
-            if field.type == CustomFieldTypeChoices.TYPE_SELECT and value is not None:
+            if field.type == CustomFieldTypeChoices.TYPE_SELECT and value:
                 instance.custom_fields[field.name] = CustomFieldChoiceSerializer(value).data
             else:
                 instance.custom_fields[field.name] = value

+ 1 - 0
netbox/extras/api/views.py

@@ -140,6 +140,7 @@ class ImageAttachmentViewSet(ModelViewSet):
     metadata_class = ContentTypeMetadata
     queryset = ImageAttachment.objects.all()
     serializer_class = serializers.ImageAttachmentSerializer
+    filterset_class = filters.ImageAttachmentFilterSet
 
 
 #

+ 9 - 1
netbox/extras/filters.py

@@ -7,7 +7,7 @@ from tenancy.models import Tenant, TenantGroup
 from utilities.filters import BaseFilterSet
 from virtualization.models import Cluster, ClusterGroup
 from .choices import *
-from .models import ConfigContext, CustomField, Graph, ExportTemplate, ObjectChange, JobResult, Tag
+from .models import ConfigContext, CustomField, ExportTemplate, Graph, ImageAttachment, JobResult, ObjectChange, Tag
 
 
 __all__ = (
@@ -17,6 +17,7 @@ __all__ = (
     'CustomFieldFilterSet',
     'ExportTemplateFilterSet',
     'GraphFilterSet',
+    'ImageAttachmentFilterSet',
     'LocalConfigContextFilterSet',
     'ObjectChangeFilterSet',
     'TagFilterSet',
@@ -104,6 +105,13 @@ class ExportTemplateFilterSet(BaseFilterSet):
         fields = ['id', 'content_type', 'name', 'template_language']
 
 
+class ImageAttachmentFilterSet(BaseFilterSet):
+
+    class Meta:
+        model = ImageAttachment
+        fields = ['id', 'content_type', 'object_id', 'name']
+
+
 class TagFilterSet(BaseFilterSet):
     q = django_filters.CharFilter(
         method='search',

+ 80 - 2
netbox/extras/tests/test_filters.py

@@ -1,11 +1,11 @@
 from django.contrib.contenttypes.models import ContentType
 from django.test import TestCase
 
-from dcim.models import DeviceRole, Platform, Region, Site
+from dcim.models import DeviceRole, Platform, Rack, Region, Site
 from extras.choices import *
 from extras.filters import *
 from extras.utils import FeatureQuery
-from extras.models import ConfigContext, ExportTemplate, Graph, Tag
+from extras.models import ConfigContext, ExportTemplate, Graph, ImageAttachment, Tag
 from tenancy.models import Tenant, TenantGroup
 from virtualization.models import Cluster, ClusterGroup, ClusterType
 
@@ -78,6 +78,84 @@ class ExportTemplateTestCase(TestCase):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
+class ImageAttachmentTestCase(TestCase):
+    queryset = ImageAttachment.objects.all()
+    filterset = ImageAttachmentFilterSet
+
+    @classmethod
+    def setUpTestData(cls):
+
+        site_ct = ContentType.objects.get(app_label='dcim', model='site')
+        rack_ct = ContentType.objects.get(app_label='dcim', model='rack')
+
+        sites = (
+            Site(name='Site 1', slug='site-1'),
+            Site(name='Site 2', slug='site-2'),
+        )
+        Site.objects.bulk_create(sites)
+
+        racks = (
+            Rack(name='Rack 1', site=sites[0]),
+            Rack(name='Rack 2', site=sites[1]),
+        )
+        Rack.objects.bulk_create(racks)
+
+        image_attachments = (
+            ImageAttachment(
+                content_type=site_ct,
+                object_id=sites[0].pk,
+                name='Image Attachment 1',
+                image='http://example.com/image1.png',
+                image_height=100,
+                image_width=100
+            ),
+            ImageAttachment(
+                content_type=site_ct,
+                object_id=sites[1].pk,
+                name='Image Attachment 2',
+                image='http://example.com/image2.png',
+                image_height=100,
+                image_width=100
+            ),
+            ImageAttachment(
+                content_type=rack_ct,
+                object_id=racks[0].pk,
+                name='Image Attachment 3',
+                image='http://example.com/image3.png',
+                image_height=100,
+                image_width=100
+            ),
+            ImageAttachment(
+                content_type=rack_ct,
+                object_id=racks[1].pk,
+                name='Image Attachment 4',
+                image='http://example.com/image4.png',
+                image_height=100,
+                image_width=100
+            )
+        )
+        ImageAttachment.objects.bulk_create(image_attachments)
+
+    def test_id(self):
+        params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_name(self):
+        params = {'name': ['Image Attachment 1', 'Image Attachment 2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_content_type(self):
+        params = {'content_type': ContentType.objects.get(app_label='dcim', model='site').pk}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_content_type_and_object_id(self):
+        params = {
+            'content_type': ContentType.objects.get(app_label='dcim', model='site').pk,
+            'object_id': [Site.objects.first().pk],
+        }
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+
 class ConfigContextTestCase(TestCase):
     queryset = ConfigContext.objects.all()
     filterset = ConfigContextFilterSet

+ 6 - 6
netbox/ipam/tables.py

@@ -67,11 +67,7 @@ IPADDRESS_LINK = """
 """
 
 IPADDRESS_ASSIGN_LINK = """
-{% if request.GET %}
-    <a href="{% url 'ipam:ipaddress_edit' pk=record.pk %}?interface={{ request.GET.interface }}&return_url={{ request.GET.return_url }}">{{ record }}</a>
-{% else %}
-    <a href="{% url 'ipam:ipaddress_edit' pk=record.pk %}?interface={{ record.interface.pk }}&return_url={{ request.path }}">{{ record }}</a>
-{% endif %}
+<a href="{% url 'ipam:ipaddress_edit' pk=record.pk %}?{% if request.GET.interface %}interface={{ request.GET.interface }}{% elif request.GET.vminterface %}vminterface={{ request.GET.vminterface }}{% endif %}&return_url={{ request.GET.return_url }}">{{ record }}</a>
 """
 
 VRF_LINK = """
@@ -103,7 +99,7 @@ VLAN_LINK = """
 """
 
 VLAN_PREFIXES = """
-{% for prefix in record.prefixes.unrestricted %}
+{% for prefix in record.prefixes.all %}
     <a href="{% url 'ipam:prefix' pk=prefix.pk %}">{{ prefix }}</a>{% if not forloop.last %}<br />{% endif %}
 {% empty %}
     &mdash;
@@ -419,6 +415,10 @@ class IPAddressDetailTable(IPAddressTable):
     tenant = tables.TemplateColumn(
         template_code=COL_TENANT
     )
+    assigned = tables.BooleanColumn(
+        accessor='assigned_object_id',
+        verbose_name='Assigned'
+    )
     tags = TagColumn(
         url_name='ipam:ipaddress_list'
     )

+ 2 - 2
netbox/ipam/views.py

@@ -582,7 +582,7 @@ class IPAddressAssignView(ObjectView):
     def dispatch(self, request, *args, **kwargs):
 
         # Redirect user if an interface has not been provided
-        if 'interface' not in request.GET:
+        if 'interface' not in request.GET and 'vminterface' not in request.GET:
             return redirect('ipam:ipaddress_add')
 
         return super().dispatch(request, *args, **kwargs)
@@ -609,7 +609,7 @@ class IPAddressAssignView(ObjectView):
         return render(request, 'ipam/ipaddress_assign.html', {
             'form': form,
             'table': table,
-            'return_url': request.GET.get('return_url', ''),
+            'return_url': request.GET.get('return_url'),
         })
 
 

+ 1 - 1
netbox/netbox/settings.py

@@ -16,7 +16,7 @@ from django.core.validators import URLValidator
 # Environment setup
 #
 
-VERSION = '2.9.2'
+VERSION = '2.9.3'
 
 # Hostname
 HOSTNAME = platform.node()

+ 16 - 0
netbox/templates/dcim/device_component_edit.html

@@ -0,0 +1,16 @@
+{% extends 'utilities/obj_edit.html' %}
+{% load form_helpers %}
+
+{% block form_fields %}
+    {% if form.instance.device %}
+        <div class="form-group">
+            <label class="col-md-3 control-label required" for="id_device">Device</label>
+            <div class="col-md-9">
+                <p class="form-control-static">
+                    <a href="{{ form.instance.device.get_absolute_url }}">{{ form.instance.device }}</a>
+                </p>
+            </div>
+        </div>
+    {% endif %}
+    {% render_form form %}
+{% endblock %}

+ 1 - 1
netbox/templates/dcim/inc/consoleport.html

@@ -66,7 +66,7 @@
             </span>
         {% endif %}
         {% if perms.dcim.change_consoleport %}
-            <a href="{% url 'dcim:consoleport_edit' pk=cp.pk %}" title="Edit port" class="btn btn-info btn-xs">
+            <a href="{% url 'dcim:consoleport_edit' pk=cp.pk %}?return_url={{ device.get_absolute_url }}" title="Edit port" class="btn btn-info btn-xs">
                 <i class="glyphicon glyphicon-pencil" aria-hidden="true"></i>
             </a>
         {% endif %}

+ 1 - 1
netbox/templates/dcim/inc/consoleserverport.html

@@ -68,7 +68,7 @@
             </span>
         {% endif %}
         {% if perms.dcim.change_consoleserverport %}
-            <a href="{% url 'dcim:consoleserverport_edit' pk=csp.pk %}" title="Edit port" class="btn btn-info btn-xs">
+            <a href="{% url 'dcim:consoleserverport_edit' pk=csp.pk %}?return_url={{ device.get_absolute_url }}" title="Edit port" class="btn btn-info btn-xs">
                 <i class="glyphicon glyphicon-pencil" aria-hidden="true"></i>
             </a>
         {% endif %}

+ 1 - 1
netbox/templates/dcim/inc/devicebay.html

@@ -52,7 +52,7 @@
                     <i class="glyphicon glyphicon-plus" aria-hidden="true" title="Install device"></i>
                 </a>
             {% endif %}
-            <a href="{% url 'dcim:devicebay_edit' pk=devicebay.pk %}" class="btn btn-info btn-xs">
+            <a href="{% url 'dcim:devicebay_edit' pk=devicebay.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-info btn-xs">
                 <i class="glyphicon glyphicon-pencil" aria-hidden="true" title="Edit device bay"></i>
             </a>
         {% endif %}

+ 1 - 1
netbox/templates/dcim/inc/poweroutlet.html

@@ -81,7 +81,7 @@
             </a>
         {% endif %}
         {% if perms.dcim.change_poweroutlet %}
-            <a href="{% url 'dcim:poweroutlet_edit' pk=po.pk %}" title="Edit outlet" class="btn btn-info btn-xs">
+            <a href="{% url 'dcim:poweroutlet_edit' pk=po.pk %}?return_url={{ device.get_absolute_url }}" title="Edit outlet" class="btn btn-info btn-xs">
                 <i class="glyphicon glyphicon-pencil" aria-hidden="true"></i>
             </a>
         {% endif %}

+ 1 - 1
netbox/templates/dcim/inc/powerport.html

@@ -78,7 +78,7 @@
             </span>
         {% endif %}
         {% if perms.dcim.change_powerport %}
-            <a href="{% url 'dcim:powerport_edit' pk=pp.pk %}" title="Edit port" class="btn btn-info btn-xs">
+            <a href="{% url 'dcim:powerport_edit' pk=pp.pk %}?return_url={{ device.get_absolute_url }}" title="Edit port" class="btn btn-info btn-xs">
                 <i class="glyphicon glyphicon-pencil" aria-hidden="true"></i>
             </a>
         {% endif %}

+ 15 - 0
netbox/templates/dcim/interface_edit.html

@@ -5,6 +5,16 @@
     <div class="panel panel-default">
         <div class="panel-heading"><strong>Interface</strong></div>
         <div class="panel-body">
+            {% if form.instance.device %}
+                <div class="form-group">
+                    <label class="col-md-3 control-label required" for="id_device">Device</label>
+                    <div class="col-md-9">
+                        <p class="form-control-static">
+                            <a href="{{ form.instance.device.get_absolute_url }}">{{ form.instance.device }}</a>
+                        </p>
+                    </div>
+                </div>
+            {% endif %}
             {% render_field form.name %}
             {% render_field form.label %}
             {% render_field form.type %}
@@ -14,6 +24,11 @@
             {% render_field form.mtu %}
             {% render_field form.mgmt_only %}
             {% render_field form.description %}
+        </div>
+    </div>
+    <div class="panel panel-default">
+        <div class="panel-heading"><strong>802.1Q Switching</strong></div>
+        <div class="panel-body">
             {% render_field form.mode %}
             {% render_field form.untagged_vlan %}
             {% render_field form.tagged_vlans %}

+ 16 - 12
netbox/templates/inc/plugin_menu_items.html

@@ -5,18 +5,22 @@
         {% for section_name, menu_items in registry.plugin_menu_items.items %}
             <li class="dropdown-header">{{ section_name }}</li>
             {% for menu_item in menu_items %}
-                <li{% if menu_item.permissions and not request.user|has_perms:menu_item.permissions %} class="disabled"{% endif %}>
-                    {% if menu_item.buttons %}
-                        <div class="buttons pull-right">
-                            {% for button in menu_item.buttons %}
-                                {% if not button.permissions or request.user|has_perms:button.permissions %}
-                                    <a href="{% url button.link %}" class="btn btn-xs btn-{{ button.color }}" title="{{ button.title }}"><i class="{{ button.icon_class }}"></i></a>
-                                {% endif %}
-                            {% endfor %}
-                        </div>
-                    {% endif %}
-                    <a href="{% url menu_item.link %}">{{ menu_item.link_text }}</a>
-                </li>
+                {% if not menu_item.permissions or request.user|has_perms:menu_item.permissions %}
+                    <li>
+                        {% if menu_item.buttons %}
+                            <div class="buttons pull-right">
+                                {% for button in menu_item.buttons %}
+                                    {% if not button.permissions or request.user|has_perms:button.permissions %}
+                                        <a href="{% url button.link %}" class="btn btn-xs btn-{{ button.color }}" title="{{ button.title }}"><i class="{{ button.icon_class }}"></i></a>
+                                    {% endif %}
+                                {% endfor %}
+                            </div>
+                        {% endif %}
+                        <a href="{% url menu_item.link %}">{{ menu_item.link_text }}</a>
+                    </li>
+                {% else %}
+                    <li class="disabled"><a href="#">{{ menu_item.link_text }}</a></li>
+                {% endif %}
             {% endfor %}
             {% if not forloop.last %}
                 <li class="divider"></li>

+ 3 - 1
netbox/templates/utilities/obj_edit.html

@@ -31,7 +31,9 @@
                     <div class="panel panel-default">
                         <div class="panel-heading"><strong>{{ obj_type|capfirst }}</strong></div>
                         <div class="panel-body">
-                            {% render_form form %}
+                            {% block form_fields %}
+                                {% render_form form %}
+                            {% endblock %}
                         </div>
                     </div>
                 {% endblock %}

+ 1 - 1
netbox/templates/virtualization/virtualmachine_component_add.html

@@ -2,7 +2,7 @@
 {% load helpers %}
 {% load form_helpers %}
 
-{% block title %}Create {{ component_type }} ({{ parent }}){% endblock %}
+{% block title %}Create {{ component_type }}{% endblock %}
 
 {% block content %}
 <form action="" method="post" class="form form-horizontal">

+ 20 - 0
netbox/templates/virtualization/vminterface_edit.html

@@ -5,14 +5,34 @@
     <div class="panel panel-default">
         <div class="panel-heading"><strong>Interface</strong></div>
         <div class="panel-body">
+            {% if form.instance.virtual_machine %}
+                <div class="form-group">
+                    <label class="col-md-3 control-label required" for="id_device">Virtual Machine</label>
+                    <div class="col-md-9">
+                        <p class="form-control-static">
+                            <a href="{{ form.instance.virtual_machine.get_absolute_url }}">{{ form.instance.virtual_machine }}</a>
+                        </p>
+                    </div>
+                </div>
+            {% endif %}
             {% render_field form.name %}
             {% render_field form.enabled %}
             {% render_field form.mac_address %}
             {% render_field form.mtu %}
             {% render_field form.description %}
+        </div>
+    </div>
+    <div class="panel panel-default">
+        <div class="panel-heading"><strong>802.1Q Switching</strong></div>
+        <div class="panel-body">
             {% render_field form.mode %}
             {% render_field form.untagged_vlan %}
             {% render_field form.tagged_vlans %}
+        </div>
+    </div>
+    <div class="panel panel-default">
+        <div class="panel-heading"><strong>Tags</strong></div>
+        <div class="panel-body">
             {% render_field form.tags %}
         </div>
     </div>

+ 18 - 8
netbox/users/views.py

@@ -38,6 +38,10 @@ class LoginView(View):
     def get(self, request):
         form = LoginForm(request)
 
+        if request.user.is_authenticated:
+            logger = logging.getLogger('netbox.auth.login')
+            return self.redirect_to_next(request, logger)
+
         return render(request, self.template_name, {
             'form': form,
         })
@@ -49,12 +53,6 @@ class LoginView(View):
         if form.is_valid():
             logger.debug("Login form validation was successful")
 
-            # Determine where to direct user after successful login
-            redirect_to = request.POST.get('next', reverse('home'))
-            if redirect_to and not is_safe_url(url=redirect_to, allowed_hosts=request.get_host()):
-                logger.warning(f"Ignoring unsafe 'next' URL passed to login form: {redirect_to}")
-                redirect_to = reverse('home')
-
             # If maintenance mode is enabled, assume the database is read-only, and disable updating the user's
             # last_login time upon authentication.
             if settings.MAINTENANCE_MODE:
@@ -66,8 +64,7 @@ class LoginView(View):
             logger.info(f"User {request.user} successfully authenticated")
             messages.info(request, "Logged in as {}.".format(request.user))
 
-            logger.debug(f"Redirecting user to {redirect_to}")
-            return HttpResponseRedirect(redirect_to)
+            return self.redirect_to_next(request, logger)
 
         else:
             logger.debug("Login form validation failed")
@@ -76,6 +73,19 @@ class LoginView(View):
             'form': form,
         })
 
+    def redirect_to_next(self, request, logger):
+        if request.method == "POST":
+            redirect_to = request.POST.get('next', reverse('home'))
+        else:
+            redirect_to = request.GET.get('next', reverse('home'))
+
+        if redirect_to and not is_safe_url(url=redirect_to, allowed_hosts=request.get_host()):
+            logger.warning(f"Ignoring unsafe 'next' URL passed to login form: {redirect_to}")
+            redirect_to = reverse('home')
+
+        logger.debug(f"Redirecting user to {redirect_to}")
+        return HttpResponseRedirect(redirect_to)
+
 
 class LogoutView(View):
     """

+ 1 - 1
netbox/utilities/tables.py

@@ -44,7 +44,7 @@ class BaseTable(tables.Table):
                     self.columns.show(name)
                 else:
                     self.columns.hide(name)
-            self.sequence = columns
+            self.sequence = [c for c in columns if c in self.base_columns]
 
             # Always include PK and actions column, if defined on the table
             if pk: