Merge pull request #5100 from netbox-community/develop

Release v2.9.3
This commit is contained in:
Jeremy Stretch 2020-09-04 15:55:47 -04:00 committed by GitHub
commit d0ac4332ab
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 262 additions and 48 deletions

View File

@ -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: Before continuing with either platform, update pip (Python's package management tool) to its latest release:
```no-highlight ```no-highlight
# pip install --upgrade pip # pip3 install --upgrade pip
``` ```
## Download NetBox ## Download NetBox

View File

@ -328,6 +328,9 @@ A `PluginMenuButton` has the following attributes:
* `color` - One of the choices provided by `ButtonColorChoices` (optional) * `color` - One of the choices provided by `ButtonColorChoices` (optional)
* `permissions` - A list of permissions required to display this button (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 ## 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: 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:

View File

@ -1,5 +1,30 @@
# NetBox v2.9 # 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) ## v2.9.2 (2020-08-27)
### Enhancements ### Enhancements

View File

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

View File

@ -149,7 +149,7 @@ class RackElevationSVG:
unit_cursor = 0 unit_cursor = 0
for u in elevation: for u in elevation:
o = other[unit_cursor] 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['device'] = o['device']
u['height'] = 1 u['height'] = 1
unit_cursor += u.get('height', 1) unit_cursor += u.get('height', 1)

View File

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

View File

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

View File

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

View File

@ -158,7 +158,7 @@ class CustomFieldModelSerializer(ValidatedModelSerializer):
instance.custom_fields = {} instance.custom_fields = {}
for field in custom_fields: for field in custom_fields:
value = instance.cf.get(field.name) 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 instance.custom_fields[field.name] = CustomFieldChoiceSerializer(value).data
else: else:
instance.custom_fields[field.name] = value instance.custom_fields[field.name] = value

View File

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

View File

@ -7,7 +7,7 @@ from tenancy.models import Tenant, TenantGroup
from utilities.filters import BaseFilterSet from utilities.filters import BaseFilterSet
from virtualization.models import Cluster, ClusterGroup from virtualization.models import Cluster, ClusterGroup
from .choices import * 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__ = ( __all__ = (
@ -17,6 +17,7 @@ __all__ = (
'CustomFieldFilterSet', 'CustomFieldFilterSet',
'ExportTemplateFilterSet', 'ExportTemplateFilterSet',
'GraphFilterSet', 'GraphFilterSet',
'ImageAttachmentFilterSet',
'LocalConfigContextFilterSet', 'LocalConfigContextFilterSet',
'ObjectChangeFilterSet', 'ObjectChangeFilterSet',
'TagFilterSet', 'TagFilterSet',
@ -104,6 +105,13 @@ class ExportTemplateFilterSet(BaseFilterSet):
fields = ['id', 'content_type', 'name', 'template_language'] fields = ['id', 'content_type', 'name', 'template_language']
class ImageAttachmentFilterSet(BaseFilterSet):
class Meta:
model = ImageAttachment
fields = ['id', 'content_type', 'object_id', 'name']
class TagFilterSet(BaseFilterSet): class TagFilterSet(BaseFilterSet):
q = django_filters.CharFilter( q = django_filters.CharFilter(
method='search', method='search',

View File

@ -1,11 +1,11 @@
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.test import TestCase 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.choices import *
from extras.filters import * from extras.filters import *
from extras.utils import FeatureQuery 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 tenancy.models import Tenant, TenantGroup
from virtualization.models import Cluster, ClusterGroup, ClusterType from virtualization.models import Cluster, ClusterGroup, ClusterType
@ -78,6 +78,84 @@ class ExportTemplateTestCase(TestCase):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) 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): class ConfigContextTestCase(TestCase):
queryset = ConfigContext.objects.all() queryset = ConfigContext.objects.all()
filterset = ConfigContextFilterSet filterset = ConfigContextFilterSet

View File

@ -67,11 +67,7 @@ IPADDRESS_LINK = """
""" """
IPADDRESS_ASSIGN_LINK = """ IPADDRESS_ASSIGN_LINK = """
{% if request.GET %} <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>
<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 %}
""" """
VRF_LINK = """ VRF_LINK = """
@ -103,7 +99,7 @@ VLAN_LINK = """
""" """
VLAN_PREFIXES = """ 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 %} <a href="{% url 'ipam:prefix' pk=prefix.pk %}">{{ prefix }}</a>{% if not forloop.last %}<br />{% endif %}
{% empty %} {% empty %}
&mdash; &mdash;
@ -419,6 +415,10 @@ class IPAddressDetailTable(IPAddressTable):
tenant = tables.TemplateColumn( tenant = tables.TemplateColumn(
template_code=COL_TENANT template_code=COL_TENANT
) )
assigned = tables.BooleanColumn(
accessor='assigned_object_id',
verbose_name='Assigned'
)
tags = TagColumn( tags = TagColumn(
url_name='ipam:ipaddress_list' url_name='ipam:ipaddress_list'
) )

View File

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

View File

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

View File

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

View File

@ -66,7 +66,7 @@
</span> </span>
{% endif %} {% endif %}
{% if perms.dcim.change_consoleport %} {% 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> <i class="glyphicon glyphicon-pencil" aria-hidden="true"></i>
</a> </a>
{% endif %} {% endif %}

View File

@ -68,7 +68,7 @@
</span> </span>
{% endif %} {% endif %}
{% if perms.dcim.change_consoleserverport %} {% 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> <i class="glyphicon glyphicon-pencil" aria-hidden="true"></i>
</a> </a>
{% endif %} {% endif %}

View File

@ -52,7 +52,7 @@
<i class="glyphicon glyphicon-plus" aria-hidden="true" title="Install device"></i> <i class="glyphicon glyphicon-plus" aria-hidden="true" title="Install device"></i>
</a> </a>
{% endif %} {% 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> <i class="glyphicon glyphicon-pencil" aria-hidden="true" title="Edit device bay"></i>
</a> </a>
{% endif %} {% endif %}

View File

@ -81,7 +81,7 @@
</a> </a>
{% endif %} {% endif %}
{% if perms.dcim.change_poweroutlet %} {% 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> <i class="glyphicon glyphicon-pencil" aria-hidden="true"></i>
</a> </a>
{% endif %} {% endif %}

View File

@ -78,7 +78,7 @@
</span> </span>
{% endif %} {% endif %}
{% if perms.dcim.change_powerport %} {% 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> <i class="glyphicon glyphicon-pencil" aria-hidden="true"></i>
</a> </a>
{% endif %} {% endif %}

View File

@ -5,6 +5,16 @@
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"><strong>Interface</strong></div> <div class="panel-heading"><strong>Interface</strong></div>
<div class="panel-body"> <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.name %}
{% render_field form.label %} {% render_field form.label %}
{% render_field form.type %} {% render_field form.type %}
@ -14,6 +24,11 @@
{% render_field form.mtu %} {% render_field form.mtu %}
{% render_field form.mgmt_only %} {% render_field form.mgmt_only %}
{% render_field form.description %} {% 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.mode %}
{% render_field form.untagged_vlan %} {% render_field form.untagged_vlan %}
{% render_field form.tagged_vlans %} {% render_field form.tagged_vlans %}

View File

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

View File

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

View File

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

View File

@ -5,14 +5,34 @@
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"><strong>Interface</strong></div> <div class="panel-heading"><strong>Interface</strong></div>
<div class="panel-body"> <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.name %}
{% render_field form.enabled %} {% render_field form.enabled %}
{% render_field form.mac_address %} {% render_field form.mac_address %}
{% render_field form.mtu %} {% render_field form.mtu %}
{% render_field form.description %} {% 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.mode %}
{% render_field form.untagged_vlan %} {% render_field form.untagged_vlan %}
{% render_field form.tagged_vlans %} {% 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 %} {% render_field form.tags %}
</div> </div>
</div> </div>

View File

@ -38,6 +38,10 @@ class LoginView(View):
def get(self, request): def get(self, request):
form = LoginForm(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, { return render(request, self.template_name, {
'form': form, 'form': form,
}) })
@ -49,12 +53,6 @@ class LoginView(View):
if form.is_valid(): if form.is_valid():
logger.debug("Login form validation was successful") 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 # If maintenance mode is enabled, assume the database is read-only, and disable updating the user's
# last_login time upon authentication. # last_login time upon authentication.
if settings.MAINTENANCE_MODE: if settings.MAINTENANCE_MODE:
@ -66,8 +64,7 @@ class LoginView(View):
logger.info(f"User {request.user} successfully authenticated") logger.info(f"User {request.user} successfully authenticated")
messages.info(request, "Logged in as {}.".format(request.user)) messages.info(request, "Logged in as {}.".format(request.user))
logger.debug(f"Redirecting user to {redirect_to}") return self.redirect_to_next(request, logger)
return HttpResponseRedirect(redirect_to)
else: else:
logger.debug("Login form validation failed") logger.debug("Login form validation failed")
@ -76,6 +73,19 @@ class LoginView(View):
'form': form, '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): class LogoutView(View):
""" """

View File

@ -44,7 +44,7 @@ class BaseTable(tables.Table):
self.columns.show(name) self.columns.show(name)
else: else:
self.columns.hide(name) 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 # Always include PK and actions column, if defined on the table
if pk: if pk: