diff --git a/docs/installation/3-netbox.md b/docs/installation/3-netbox.md
index 05f6d825e..235e39a8f 100644
--- a/docs/installation/3-netbox.md
+++ b/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
diff --git a/docs/plugins/development.md b/docs/plugins/development.md
index b704ad7fc..f4db3c84d 100644
--- a/docs/plugins/development.md
+++ b/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:
diff --git a/docs/release-notes/version-2.9.md b/docs/release-notes/version-2.9.md
index e853a06e4..a3d500094 100644
--- a/docs/release-notes/version-2.9.md
+++ b/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
diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py
index dc12e686e..fa4f81792 100644
--- a/netbox/dcim/choices.py
+++ b/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'),
diff --git a/netbox/dcim/elevations.py b/netbox/dcim/elevations.py
index 5a22188b8..93c44f087 100644
--- a/netbox/dcim/elevations.py
+++ b/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)
diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py
index b6ba55d6d..43f77de51 100644
--- a/netbox/dcim/forms.py
+++ b/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(),
diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py
index 78fa1dea6..371eff9db 100644
--- a/netbox/dcim/tables.py
+++ b/netbox/dcim/tables.py
@@ -152,6 +152,10 @@ INTERFACE_TAGGED_VLANS = """
{% endfor %}
"""
+CONNECTION_STATUS = """
+{{ record.get_connection_status_display }}
+"""
+
#
# 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
diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py
index c016f6e54..9f4dcc90e 100644
--- a/netbox/dcim/views.py
+++ b/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):
diff --git a/netbox/extras/api/customfields.py b/netbox/extras/api/customfields.py
index 5ef983977..f096fb4a6 100644
--- a/netbox/extras/api/customfields.py
+++ b/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
diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py
index 289a51c83..a63dbe44d 100644
--- a/netbox/extras/api/views.py
+++ b/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
#
diff --git a/netbox/extras/filters.py b/netbox/extras/filters.py
index e8962da01..1af98e885 100644
--- a/netbox/extras/filters.py
+++ b/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',
diff --git a/netbox/extras/tests/test_filters.py b/netbox/extras/tests/test_filters.py
index 72db138e2..d6e077db4 100644
--- a/netbox/extras/tests/test_filters.py
+++ b/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
diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py
index 7b4aa44ba..d7a64f7db 100644
--- a/netbox/ipam/tables.py
+++ b/netbox/ipam/tables.py
@@ -67,11 +67,7 @@ IPADDRESS_LINK = """
"""
IPADDRESS_ASSIGN_LINK = """
-{% if request.GET %}
- {{ record }}
-{% else %}
- {{ record }}
-{% endif %}
+{{ record }}
"""
VRF_LINK = """
@@ -103,7 +99,7 @@ VLAN_LINK = """
"""
VLAN_PREFIXES = """
-{% for prefix in record.prefixes.unrestricted %}
+{% for prefix in record.prefixes.all %}
{{ prefix }}{% if not forloop.last %}
{% endif %}
{% empty %}
—
@@ -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'
)
diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py
index 68f7da8ad..1f0e2607e 100644
--- a/netbox/ipam/views.py
+++ b/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'),
})
diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py
index 61dc9cd72..c48db1493 100644
--- a/netbox/netbox/settings.py
+++ b/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()
diff --git a/netbox/templates/dcim/device_component_edit.html b/netbox/templates/dcim/device_component_edit.html
new file mode 100644
index 000000000..e0f1a2326
--- /dev/null
+++ b/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 %}
+