Merge pull request #5694 from netbox-community/develop

Release v2.10.4
This commit is contained in:
Jeremy Stretch 2021-01-26 16:01:00 -05:00 committed by GitHub
commit 856d2e3176
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 184 additions and 63 deletions

View File

@ -12,8 +12,7 @@ complete list of requirements, see `requirements.txt`. The code is available [on
The complete documentation for NetBox can be found at [Read the Docs](https://netbox.readthedocs.io/en/stable/).
Questions? Comments? Please start a [discussion on GitHub](https://github.com/netbox-community/netbox/discussions),
or join us in the **#netbox** Slack channel on [NetworkToCode](https://networktocode.slack.com/)!
Questions? Comments? Start by perusing our [GitHub discussions](https://github.com/netbox-community/netbox/discussions) for the topic you have in mind.
### Build Status

View File

@ -56,7 +56,7 @@ BASE_PATH = 'netbox/'
Default: 900
The number of seconds to cache entries will be retained before expiring.
The number of seconds that cache entries will be retained before expiring.
---

View File

@ -5,7 +5,7 @@
This is a list of valid fully-qualified domain names (FQDNs) and/or IP addresses that can be used to reach the NetBox service. Usually this is the same as the hostname for the NetBox server, but can also be different; for example, when using a reverse proxy serving the NetBox website under a different FQDN than the hostname of the NetBox server. To help guard against [HTTP Host header attackes](https://docs.djangoproject.com/en/3.0/topics/security/#host-headers-virtual-hosting), NetBox will not permit access to the server via any other hostnames (or IPs).
!!! note
This parameter must always be defined as a list or tuple, even if only value is provided.
This parameter must always be defined as a list or tuple, even if only a single value is provided.
The value of this option is also used to set `CSRF_TRUSTED_ORIGINS`, which restricts POST requests to the same set of hosts (more about this [here](https://docs.djangoproject.com/en/stable/ref/settings/#std:setting-CSRF_TRUSTED_ORIGINS)). Keep in mind that NetBox, by default, sets `USE_X_FORWARDED_HOST` to true, which means that if you're using a reverse proxy, it's the FQDN used to reach that reverse proxy which needs to be in this list (more about this [here](https://docs.djangoproject.com/en/stable/ref/settings/#allowed-hosts)).
@ -101,7 +101,7 @@ REDIS = {
If you are using [Redis Sentinel](https://redis.io/topics/sentinel) for high-availability purposes, there is minimal
configuration necessary to convert NetBox to recognize it. It requires the removal of the `HOST` and `PORT` keys from
above and the addition of two new keys.
above and the addition of three new keys.
* `SENTINELS`: List of tuples or tuple of tuples with each inner tuple containing the name or IP address
of the Redis server and port for each sentinel instance to connect to

View File

@ -7,12 +7,12 @@ This section of the documentation discusses installing and configuring the NetBo
Begin by installing all system packages required by NetBox and its dependencies.
!!! note
NetBox v2.8.0 and later require Python 3.6, 3.7, or 3.8. This documentation assumes Python 3.6.
NetBox v2.8.0 and later require Python 3.6, 3.7, or 3.8.
### Ubuntu
```no-highlight
sudo apt install -y python3.6 python3-pip python3-venv python3-dev build-essential libxml2-dev libxslt1-dev libffi-dev libpq-dev libssl-dev zlib1g-dev
sudo apt install -y python3 python3-pip python3-venv python3-dev build-essential libxml2-dev libxslt1-dev libffi-dev libpq-dev libssl-dev zlib1g-dev
```
### CentOS

View File

@ -11,6 +11,10 @@ The following sections detail how to set up a new instance of NetBox:
5. [HTTP server](5-http-server.md)
6. [LDAP authentication](6-ldap.md) (optional)
The video below demonstrates the installation of NetBox v2.10.3 on Ubuntu 20.04 for your reference.
<iframe width="560" height="315" src="https://www.youtube.com/embed/dFANGlxXEng" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>
## Requirements
| Dependency | Minimum Version |

View File

@ -1,5 +1,30 @@
# NetBox v2.10
## v2.10.4 (2021-01-26)
### Enhancements
* [#5542](https://github.com/netbox-community/netbox/issues/5542) - Show cable trace lengths in both meters and feet
* [#5570](https://github.com/netbox-community/netbox/issues/5570) - Add "management only" filter widget for interfaces list
* [#5586](https://github.com/netbox-community/netbox/issues/5586) - Allow filtering virtual chassis by name and master
* [#5612](https://github.com/netbox-community/netbox/issues/5612) - Add GG45 and TERA port types, and CAT7a and CAT8 cable types
* [#5678](https://github.com/netbox-community/netbox/issues/5678) - Show available type choices for all device component import forms
### Bug Fixes
* [#5232](https://github.com/netbox-community/netbox/issues/5232) - Correct swagger definition for ip_prefixes_available-ips_create API
* [#5574](https://github.com/netbox-community/netbox/issues/5574) - Restrict the creation of device bay templates on non-parent device types
* [#5584](https://github.com/netbox-community/netbox/issues/5584) - Restore power utilization panel under device view
* [#5597](https://github.com/netbox-community/netbox/issues/5597) - Fix ordering devices by primary IP address
* [#5603](https://github.com/netbox-community/netbox/issues/5603) - Fix display of white cables in trace view
* [#5639](https://github.com/netbox-community/netbox/issues/5639) - Fix filtering connection lists by device name
* [#5640](https://github.com/netbox-community/netbox/issues/5640) - Fix permissions assessment when adding VM interfaces in bulk
* [#5648](https://github.com/netbox-community/netbox/issues/5648) - Include VC member interfaces on interfaces tab count when viewing VC master
* [#5665](https://github.com/netbox-community/netbox/issues/5665) - Validate rack group is assigned to same site when creating a rack
* [#5683](https://github.com/netbox-community/netbox/issues/5683) - Correct rack elevation displayed when viewing a reservation
---
## v2.10.3 (2021-01-05)
### Bug Fixes

View File

@ -873,6 +873,10 @@ class PortTypeChoices(ChoiceSet):
TYPE_8P6C = '8p6c'
TYPE_8P4C = '8p4c'
TYPE_8P2C = '8p2c'
TYPE_GG45 = 'gg45'
TYPE_TERA4P = 'tera-4p'
TYPE_TERA2P = 'tera-2p'
TYPE_TERA1P = 'tera-1p'
TYPE_110_PUNCH = '110-punch'
TYPE_BNC = 'bnc'
TYPE_MRJ21 = 'mrj21'
@ -898,6 +902,10 @@ class PortTypeChoices(ChoiceSet):
(TYPE_8P6C, '8P6C'),
(TYPE_8P4C, '8P4C'),
(TYPE_8P2C, '8P2C'),
(TYPE_GG45, 'GG45'),
(TYPE_TERA4P, 'TERA 4P'),
(TYPE_TERA2P, 'TERA 2P'),
(TYPE_TERA1P, 'TERA 1P'),
(TYPE_110_PUNCH, '110 Punch'),
(TYPE_BNC, 'BNC'),
(TYPE_MRJ21, 'MRJ21'),
@ -936,6 +944,8 @@ class CableTypeChoices(ChoiceSet):
TYPE_CAT6 = 'cat6'
TYPE_CAT6A = 'cat6a'
TYPE_CAT7 = 'cat7'
TYPE_CAT7A = 'cat7a'
TYPE_CAT8 = 'cat8'
TYPE_DAC_ACTIVE = 'dac-active'
TYPE_DAC_PASSIVE = 'dac-passive'
TYPE_MRJ21_TRUNK = 'mrj21-trunk'
@ -960,6 +970,8 @@ class CableTypeChoices(ChoiceSet):
(TYPE_CAT6, 'CAT6'),
(TYPE_CAT6A, 'CAT6a'),
(TYPE_CAT7, 'CAT7'),
(TYPE_CAT7A, 'CAT7a'),
(TYPE_CAT8, 'CAT8'),
(TYPE_DAC_ACTIVE, 'Direct Attach Copper (Active)'),
(TYPE_DAC_PASSIVE, 'Direct Attach Copper (Passive)'),
(TYPE_MRJ21_TRUNK, 'MRJ21 Trunk'),

View File

@ -1016,6 +1016,16 @@ class VirtualChassisFilterSet(BaseFilterSet):
method='search',
label='Search',
)
master_id = django_filters.ModelMultipleChoiceFilter(
queryset=Device.objects.all(),
label='Master (ID)',
)
master = django_filters.ModelMultipleChoiceFilter(
field_name='master__name',
queryset=Device.objects.all(),
to_field_name='name',
label='Master (name)',
)
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='master__site__region',
@ -1055,7 +1065,7 @@ class VirtualChassisFilterSet(BaseFilterSet):
class Meta:
model = VirtualChassis
fields = ['id', 'domain']
fields = ['id', 'domain', 'name']
def search(self, queryset, name, value):
if not value.strip():
@ -1142,7 +1152,7 @@ class ConnectionFilterSet:
def filter_device(self, queryset, name, value):
if not value:
return queryset
return queryset.filter(device_id__in=value)
return queryset.filter(**{f'{name}__in': value})
class ConsoleConnectionFilterSet(ConnectionFilterSet, BaseFilterSet):

View File

@ -2352,6 +2352,11 @@ class ConsolePortCSVForm(CSVModelForm):
queryset=Device.objects.all(),
to_field_name='name'
)
type = CSVChoiceField(
choices=ConsolePortTypeChoices,
required=False,
help_text='Port type'
)
class Meta:
model = ConsolePort
@ -2425,6 +2430,11 @@ class ConsoleServerPortCSVForm(CSVModelForm):
queryset=Device.objects.all(),
to_field_name='name'
)
type = CSVChoiceField(
choices=ConsolePortTypeChoices,
required=False,
help_text='Port type'
)
class Meta:
model = ConsoleServerPort
@ -2510,6 +2520,11 @@ class PowerPortCSVForm(CSVModelForm):
queryset=Device.objects.all(),
to_field_name='name'
)
type = CSVChoiceField(
choices=PowerPortTypeChoices,
required=False,
help_text='Port type'
)
class Meta:
model = PowerPort
@ -2630,6 +2645,11 @@ class PowerOutletCSVForm(CSVModelForm):
queryset=Device.objects.all(),
to_field_name='name'
)
type = CSVChoiceField(
choices=PowerOutletTypeChoices,
required=False,
help_text='Outlet type'
)
power_port = CSVModelChoiceField(
queryset=PowerPort.objects.all(),
required=False,
@ -2687,6 +2707,12 @@ class InterfaceFilterForm(DeviceComponentFilterForm):
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
mgmt_only = forms.NullBooleanField(
required=False,
widget=StaticSelect2(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
mac_address = forms.CharField(
required=False,
label='MAC address'

View File

@ -363,3 +363,9 @@ class DeviceBayTemplate(ComponentTemplateModel):
name=self.name,
label=self.label
)
def clean(self):
if self.device_type and self.device_type.subdevice_role != SubdeviceRoleChoices.ROLE_PARENT:
raise ValidationError(
f"Subdevice role of device type ({self.device_type}) must be set to \"parent\" to allow device bays."
)

View File

@ -299,6 +299,10 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
def clean(self):
super().clean()
# Validate group/site assignment
if self.site and self.group and self.group.site != self.site:
raise ValidationError(f"Assigned rack group must belong to parent site ({self.site}).")
# Validate outer dimensions and unit
if (self.outer_width is not None or self.outer_depth is not None) and not self.outer_unit:
raise ValidationError("Must specify a unit when setting an outer width/depth")

View File

@ -129,6 +129,7 @@ class DeviceTable(BaseTable):
)
primary_ip = tables.Column(
linkify=True,
order_by=('primary_ip6', 'primary_ip4'),
verbose_name='IP Address'
)
primary_ip4 = tables.Column(
@ -406,6 +407,7 @@ class BaseInterfaceTable(BaseTable):
class InterfaceTable(DeviceComponentTable, BaseInterfaceTable, PathEndpointTable):
mgmt_only = BooleanColumn()
tags = TagColumn(
url_name='dcim:interface_list'
)

View File

@ -740,7 +740,10 @@ class DeviceBayTemplateTest(APIViewTestCases.APIViewTestCase):
def setUpTestData(cls):
manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
devicetype = DeviceType.objects.create(
manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'
manufacturer=manufacturer,
model='Device Type 1',
slug='device-type-1',
subdevice_role=SubdeviceRoleChoices.ROLE_PARENT
)
device_bay_templates = (

View File

@ -2399,9 +2399,9 @@ class VirtualChassisTestCase(TestCase):
Device.objects.bulk_create(devices)
virtual_chassis = (
VirtualChassis(master=devices[0], domain='Domain 1'),
VirtualChassis(master=devices[2], domain='Domain 2'),
VirtualChassis(master=devices[4], domain='Domain 3'),
VirtualChassis(name='VC 1', master=devices[0], domain='Domain 1'),
VirtualChassis(name='VC 2', master=devices[2], domain='Domain 2'),
VirtualChassis(name='VC 3', master=devices[4], domain='Domain 3'),
)
VirtualChassis.objects.bulk_create(virtual_chassis)
@ -2417,6 +2417,17 @@ class VirtualChassisTestCase(TestCase):
params = {'domain': ['Domain 1', 'Domain 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_master(self):
masters = Device.objects.all()
params = {'master_id': [masters[0].pk, masters[2].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'master': [masters[0].name, masters[2].name]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_name(self):
params = {'name': ['VC 1', 'VC 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_region(self):
regions = Region.objects.all()[:2]
params = {'region_id': [regions[0].pk, regions[1].pk]}

View File

@ -396,6 +396,7 @@ manufacturer: Generic
model: TEST-1000
slug: test-1000
u_height: 2
subdevice_role: parent
comments: test comment
console-ports:
- name: Console Port 1
@ -831,8 +832,8 @@ class DeviceBayTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas
def setUpTestData(cls):
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
devicetypes = (
DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1', subdevice_role=SubdeviceRoleChoices.ROLE_PARENT),
DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2', subdevice_role=SubdeviceRoleChoices.ROLE_PARENT),
)
DeviceType.objects.bulk_create(devicetypes)

View File

@ -1,7 +1,6 @@
#!/usr/bin/env python
# This script will generate a random 50-character string suitable for use as a SECRET_KEY.
import random
import secrets
charset = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*(-_=+)'
secure_random = random.SystemRandom()
print(''.join(secure_random.sample(charset, 50)))
print(''.join(secrets.choice(charset) for _ in range(50)))

View File

@ -178,7 +178,7 @@ class PrefixViewSet(CustomFieldModelViewSet):
@swagger_auto_schema(method='get', responses={200: serializers.AvailableIPSerializer(many=True)})
@swagger_auto_schema(method='post', responses={201: serializers.AvailableIPSerializer(many=True)},
request_body=serializers.AvailableIPSerializer(many=False))
request_body=serializers.AvailableIPSerializer(many=True))
@action(detail=True, url_path='available-ips', methods=['get', 'post'], queryset=IPAddress.objects.all())
@advisory_lock(ADVISORY_LOCK_KEYS['available-ips'])
def available_ips(self, request, pk=None):

View File

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

View File

@ -69,7 +69,8 @@
<h5>Total segments: {{ traced_path|length }}</h5>
<h5>Total length:
{% if total_length %}
{{ total_length|floatformat:"-2" }} Meters
{{ total_length|floatformat:"-2" }} Meters /
{{ total_length|meters_to_feet|floatformat:"-2" }} Feet
{% else %}
<span class="text-muted">N/A</span>
{% endif %}

View File

@ -204,7 +204,7 @@
{% plugin_left_page object %}
</div>
<div class="col-md-6">
{% if power_ports and poweroutlets %}
{% if object.powerports.exists and object.poweroutlets.exists %}
<div class="panel panel-default">
<div class="panel-heading">
<strong>Power Utilization</strong>
@ -217,10 +217,10 @@
<th>Available</th>
<th>Utilization</th>
</tr>
{% for pp in power_ports %}
{% with utilization=pp.get_power_draw powerfeed=pp.connected_endpoint %}
{% for powerport in object.powerports.all %}
{% with utilization=powerport.get_power_draw powerfeed=powerport.connected_endpoint %}
<tr>
<td>{{ pp }}</td>
<td>{{ powerport }}</td>
<td>{{ utilization.outlet_count }}</td>
<td>{{ utilization.allocated }}VA</td>
{% if powerfeed.available_power %}

View File

@ -90,7 +90,7 @@
<li role="presentation" {% if active_tab == 'device' %} class="active"{% endif %}>
<a href="{% url 'dcim:device' pk=object.pk %}">Device</a>
</li>
{% with interface_count=object.interfaces.count %}
{% with interface_count=object.vc_interfaces.count %}
{% if interface_count %}
<li role="presentation" {% if active_tab == 'interfaces' %} class="active"{% endif %}>
<a href="{% url 'dcim:device_interfaces' pk=object.pk %}">Interfaces {% badge interface_count %}</a>

View File

@ -127,22 +127,20 @@
{% plugin_left_page object %}
</div>
<div class="col-md-6">
{% with rack=object.rack %}
<div class="row" style="margin-bottom: 20px">
<div class="col-md-6 col-sm-6 col-xs-12">
<div class="rack_header">
<h4>Front</h4>
</div>
{% include 'dcim/inc/rack_elevation.html' with face='front' %}
</div>
<div class="col-md-6 col-sm-6 col-xs-12">
<div class="rack_header">
<h4>Rear</h4>
</div>
{% include 'dcim/inc/rack_elevation.html' with face='rear' %}
<div class="row" style="margin-bottom: 20px">
<div class="col-md-6 col-sm-6 col-xs-12">
<div class="rack_header">
<h4>Front</h4>
</div>
{% include 'dcim/inc/rack_elevation.html' with object=object.rack face='front' %}
</div>
{% endwith %}
<div class="col-md-6 col-sm-6 col-xs-12">
<div class="rack_header">
<h4>Rear</h4>
</div>
{% include 'dcim/inc/rack_elevation.html' with object=object.rack face='rear' %}
</div>
</div>
{% plugin_right_page object %}
</div>
</div>

View File

@ -1,6 +1,6 @@
{% load helpers %}
<div class="cable" style="border-left-color: #{{ cable.color|default:'606060' }}; {% if cable.status != 'connected' %} border-left-style: dashed{% endif %}">
<div class="cable" style="border-left-color: #{% if cable.color == 'ffffff' %}909090; border-left-style: double; border-left-width: 6px;{% else %}{{ cable.color|default:'606060' }};{% endif %} {% if cable.status != 'connected' %} border-left-style: dashed{% endif %}">
<strong>
<a href="{% url 'dcim:cable' pk=cable.pk %}">
{% if cable.label %}<code>{{ cable.label }}</code>{% else %}Cable #{{ cable.pk }}{% endif %}

View File

@ -7,7 +7,7 @@
<span class="mdi mdi-plus-thick" aria-hidden="true"></span> Add Components <span class="caret"></span>
</button>
<ul class="dropdown-menu">
{% if perms.dcim.add_interface %}<li><a href="{% url 'virtualization:virtualmachine_bulk_add_vminterface' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="formaction">Interfaces</a></li>{% endif %}
{% if perms.virtualization.add_vminterface %}<li><a href="{% url 'virtualization:virtualmachine_bulk_add_vminterface' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="formaction">Interfaces</a></li>{% endif %}
</ul>
</div>
{% endif %}

View File

@ -28,29 +28,38 @@ class NetBoxSwaggerAutoSchema(SwaggerAutoSchema):
serializer = super().get_request_serializer()
if serializer is not None and self.method in self.implicit_body_methods:
properties = {}
for child_name, child in serializer.fields.items():
if isinstance(child, (ChoiceField, WritableNestedSerializer)):
properties[child_name] = None
elif isinstance(child, ManyRelatedField) and isinstance(child.child_relation, SerializedPKRelatedField):
properties[child_name] = None
if properties:
if type(serializer) not in self.writable_serializers:
writable_name = 'Writable' + type(serializer).__name__
meta_class = getattr(type(serializer), 'Meta', None)
if meta_class:
ref_name = 'Writable' + get_serializer_ref_name(serializer)
writable_meta = type('Meta', (meta_class,), {'ref_name': ref_name})
properties['Meta'] = writable_meta
self.writable_serializers[type(serializer)] = type(writable_name, (type(serializer),), properties)
writable_class = self.writable_serializers[type(serializer)]
serializer = writable_class()
writable_class = self.get_writable_class(serializer)
if writable_class is not None:
if hasattr(serializer, 'child'):
child_serializer = self.get_writable_class(serializer.child)
serializer = writable_class(child=child_serializer)
else:
serializer = writable_class()
return serializer
def get_writable_class(self, serializer):
properties = {}
fields = {} if hasattr(serializer, 'child') else serializer.fields
for child_name, child in fields.items():
if isinstance(child, (ChoiceField, WritableNestedSerializer)):
properties[child_name] = None
elif isinstance(child, ManyRelatedField) and isinstance(child.child_relation, SerializedPKRelatedField):
properties[child_name] = None
if properties:
if type(serializer) not in self.writable_serializers:
writable_name = 'Writable' + type(serializer).__name__
meta_class = getattr(type(serializer), 'Meta', None)
if meta_class:
ref_name = 'Writable' + get_serializer_ref_name(serializer)
writable_meta = type('Meta', (meta_class,), {'ref_name': ref_name})
properties['Meta'] = writable_meta
self.writable_serializers[type(serializer)] = type(writable_name, (type(serializer),), properties)
writable_class = self.writable_serializers[type(serializer)]
return writable_class
class SerializedPKRelatedFieldInspector(FieldInspector):
def field_to_swagger_object(self, field, swagger_object_type, use_references, **kwargs):

View File

@ -220,6 +220,14 @@ def as_range(n):
return range(n)
@register.filter()
def meters_to_feet(n):
"""
Convert a length from meters to feet.
"""
return float(n) * 3.28084
#
# Tags
#

View File

@ -396,3 +396,6 @@ class VirtualMachineBulkAddInterfaceView(generic.BulkComponentCreateView):
model_form = forms.VMInterfaceForm
filterset = filters.VirtualMachineFilterSet
table = tables.VirtualMachineTable
def get_required_permission(self):
return f'virtualization.add_vminterface'