Merge pull request #2152 from digitalocean/develop

Release v2.3.4
This commit is contained in:
Jeremy Stretch 2018-06-07 16:14:18 -04:00 committed by GitHub
commit a1f624c1cc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 86 additions and 36 deletions

View File

@ -12,31 +12,37 @@ Download and extract the latest version:
# wget https://github.com/digitalocean/netbox/archive/vX.Y.Z.tar.gz
# tar -xzf vX.Y.Z.tar.gz -C /opt
# cd /opt/
# ln -sf netbox-X.Y.Z/ netbox
# ln -sfn netbox-X.Y.Z/ netbox
```
Copy the 'configuration.py' you created when first installing to the new version:
```no-highlight
# cp /opt/netbox-X.Y.Z/netbox/netbox/configuration.py /opt/netbox/netbox/netbox/configuration.py
# cp netbox-X.Y.Z/netbox/netbox/configuration.py netbox/netbox/netbox/configuration.py
```
Be sure to replicate your uploaded media as well. (The exact action necessary will depend on where you choose to store your media, but in general moving or copying the media directory will suffice.)
```no-highlight
# cp -pr /opt/netbox-X.Y.Z/netbox/media/ /opt/netbox/netbox/
# cp -pr netbox-X.Y.Z/netbox/media/ netbox/netbox/
```
Also make sure to copy over any reports that you've made. Note that if you made them in a separate directory (`/opt/netbox-reports` for example), then you will not need to copy them - the config file that you copied earlier will point to the correct location.
```no-highlight
# cp -r /opt/netbox-X.Y.X/netbox/reports /opt/netbox/netbox/reports/
```
If you followed the original installation guide to set up gunicorn, be sure to copy its configuration as well:
```no-highlight
# cp /opt/netbox-X.Y.Z/gunicorn_config.py /opt/netbox/gunicorn_config.py
# cp netbox-X.Y.Z/gunicorn_config.py netbox/gunicorn_config.py
```
Copy the LDAP configuration if using LDAP:
```no-highlight
# cp /opt/netbox-X.Y.Z/netbox/netbox/ldap_config.py /opt/netbox/netbox/netbox/ldap_config.py
# cp netbox-X.Y.Z/netbox/netbox/ldap_config.py netbox/netbox/netbox/ldap_config.py
```
## Option B: Clone the Git Repository (latest master release)

View File

@ -80,7 +80,7 @@ class NestedSiteSerializer(serializers.ModelSerializer):
class WritableSiteSerializer(CustomFieldModelSerializer):
time_zone = TimeZoneField(required=False)
time_zone = TimeZoneField(required=False, allow_null=True)
class Meta:
model = Site
@ -233,7 +233,7 @@ class WritableRackReservationSerializer(ValidatedModelSerializer):
class Meta:
model = RackReservation
fields = ['id', 'rack', 'units', 'user', 'description']
fields = ['id', 'rack', 'units', 'user', 'tenant', 'description']
#

View File

@ -166,13 +166,37 @@ class SiteCSVForm(forms.ModelForm):
class SiteBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=Site.objects.all(), widget=forms.MultipleHiddenInput)
status = forms.ChoiceField(choices=add_blank_choice(SITE_STATUS_CHOICES), required=False, initial='')
region = TreeNodeChoiceField(queryset=Region.objects.all(), required=False)
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
asn = forms.IntegerField(min_value=1, max_value=4294967295, required=False, label='ASN')
description = forms.CharField(max_length=100, required=False)
time_zone = TimeZoneFormField(required=False)
pk = forms.ModelMultipleChoiceField(
queryset=Site.objects.all(),
widget=forms.MultipleHiddenInput
)
status = forms.ChoiceField(
choices=add_blank_choice(SITE_STATUS_CHOICES),
required=False,
initial=''
)
region = TreeNodeChoiceField(
queryset=Region.objects.all(),
required=False
)
tenant = forms.ModelChoiceField(
queryset=Tenant.objects.all(),
required=False
)
asn = forms.IntegerField(
min_value=1,
max_value=4294967295,
required=False,
label='ASN'
)
description = forms.CharField(
max_length=100,
required=False
)
time_zone = TimeZoneFormField(
choices=add_blank_choice(TimeZoneFormField().choices),
required=False
)
class Meta:
nullable_fields = ['region', 'tenant', 'asn', 'description', 'time_zone']

View File

@ -1236,7 +1236,7 @@ class ConsoleServerPort(models.Model):
raise ValidationError("Console server ports must be assigned to devices.")
device_type = self.device.device_type
if not device_type.is_console_server:
raise ValidationError("The {} {} device type not support assignment of console server ports.".format(
raise ValidationError("The {} {} device type does not support assignment of console server ports.".format(
device_type.manufacturer, device_type
))
@ -1318,7 +1318,7 @@ class PowerOutlet(models.Model):
raise ValidationError("Power outlets must be assigned to devices.")
device_type = self.device.device_type
if not device_type.is_pdu:
raise ValidationError("The {} {} device type not support assignment of power outlets.".format(
raise ValidationError("The {} {} device type does not support assignment of power outlets.".format(
device_type.manufacturer, device_type
))
@ -1403,7 +1403,7 @@ class Interface(models.Model):
if self.device is not None:
device_type = self.device.device_type
if not device_type.is_network_device:
raise ValidationError("The {} {} device type not support assignment of network interfaces.".format(
raise ValidationError("The {} {} device type does not support assignment of network interfaces.".format(
device_type.manufacturer, device_type
))
@ -1536,6 +1536,18 @@ class InterfaceConnection(models.Model):
raise ValidationError({
'interface_b': "Cannot connect an interface to itself."
})
if self.interface_a.form_factor in NONCONNECTABLE_IFACE_TYPES:
raise ValidationError({
'interface_a': '{} is not a connectable interface type.'.format(
self.interface_a.get_form_factor_display()
)
})
if self.interface_b.form_factor in NONCONNECTABLE_IFACE_TYPES:
raise ValidationError({
'interface_b': '{} is not a connectable interface type.'.format(
self.interface_b.get_form_factor_display()
)
})
except ObjectDoesNotExist:
pass

View File

@ -11,8 +11,13 @@ def assign_virtualchassis_master(instance, created, **kwargs):
"""
When a VirtualChassis is created, automatically assign its master device to the VC.
"""
# Default to 1 but don't overwrite an existing position (see #2087)
if instance.master.vc_position is not None:
vc_position = instance.master.vc_position
else:
vc_position = 1
if created:
Device.objects.filter(pk=instance.master.pk).update(virtual_chassis=instance, vc_position=1)
Device.objects.filter(pk=instance.master.pk).update(virtual_chassis=instance, vc_position=vc_position)
@receiver(pre_delete, sender=VirtualChassis)

View File

@ -157,6 +157,7 @@ class RegionBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_region'
cls = Region
queryset = Region.objects.annotate(site_count=Count('sites'))
filter = filters.RegionFilter
table = tables.RegionTable
default_return_url = 'dcim:region_list'
@ -491,6 +492,7 @@ class RackReservationBulkEditView(PermissionRequiredMixin, BulkEditView):
class RackReservationBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_rackreservation'
cls = RackReservation
filter = filters.RackReservationFilter
table = tables.RackReservationTable
default_return_url = 'dcim:rackreservation_list'

View File

@ -2,7 +2,7 @@ from __future__ import unicode_literals
from django.core.exceptions import ValidationError
from django.db import models
from netaddr import IPNetwork
from netaddr import AddrFormatError, IPNetwork
from .formfields import IPFormField
from . import lookups
@ -26,7 +26,9 @@ class BaseIPField(models.Field):
return value
try:
return IPNetwork(value)
except ValueError as e:
except AddrFormatError as e:
raise ValidationError("Invalid IP address format: {}".format(value))
except (TypeError, ValueError) as e:
raise ValidationError(e)
def get_prep_value(self, value):

View File

@ -22,7 +22,7 @@ if sys.version_info[0] < 3:
DeprecationWarning
)
VERSION = '2.3.3'
VERSION = '2.3.4'
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))

View File

@ -53,7 +53,7 @@ $(document).ready(function() {
success: function(json) {
$.each(json['get_lldp_neighbors'], function(iface, neighbors) {
var neighbor = neighbors[0];
var row = $('#' + iface.split(".")[0].replace(/(\/)/g, "\\$1"));
var row = $('#' + iface.split(".")[0].replace(/([\/:])/g, "\\$1"));
// Glean configured hostnames/interfaces from the DOM
var configured_device = row.children('td.configured_device').attr('data');

View File

@ -105,7 +105,7 @@
<button class="btn btn-warning btn-xs interface-toggle connected" disabled="disabled" title="Circuits cannot be marked as planned or connected">
<i class="glyphicon glyphicon-ban-circle" aria-hidden="true"></i>
</button>
<a href="{% url 'circuits:circuittermination_edit' pk=iface.circuit_termination.pk %}&return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs" title="Edit circuit termination">
<a href="{% url 'circuits:circuittermination_edit' pk=iface.circuit_termination.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs" title="Edit circuit termination">
<i class="glyphicon glyphicon-resize-full" aria-hidden="true"></i>
</a>
{% else %}

View File

@ -1,22 +1,17 @@
{% extends '_base.html' %}
{% load helpers %}
{% load buttons %}
{% block content %}
<div class="pull-right">
{% if perms.dcim.add_rackrole %}
<a href="{% url 'dcim:rackrole_add' %}" class="btn btn-primary">
<span class="fa fa-plus" aria-hidden="true"></span>
Add a rack role
</a>
<a href="{% url 'dcim:rackrole_import' %}" class="btn btn-info">
<span class="fa fa-download" aria-hidden="true"></span>
Import rack roles
</a>
{% add_button 'dcim:rackrole_add' %}
{% import_button 'dcim:rackrole_import' %}
{% endif %}
{% export_button content_type %}
</div>
<h1>{% block title %}Rack Roles{% endblock %}</h1>
<div class="row">
<div class="col-md-12">
<div class="col-md-12">
{% include 'utilities/obj_table.html' with bulk_delete_url='dcim:rackrole_bulk_delete' %}
</div>
</div>

View File

@ -626,8 +626,11 @@ class BulkDeleteView(View):
return_url = reverse(self.default_return_url)
# Are we deleting *all* objects in the queryset or just a selected subset?
if request.POST.get('_all') and self.filter is not None:
pk_list = [obj.pk for obj in self.filter(request.GET, self.cls.objects.only('pk')).qs]
if request.POST.get('_all'):
if self.filter is not None:
pk_list = [obj.pk for obj in self.filter(request.GET, self.cls.objects.only('pk')).qs]
else:
pk_list = self.cls.objects.values_list('pk', flat=True)
else:
pk_list = [int(pk) for pk in request.POST.getlist('pk')]

View File

@ -115,7 +115,7 @@ class ClusterView(View):
'site', 'rack', 'tenant', 'device_type__manufacturer'
)
device_table = DeviceTable(list(devices), orderable=False)
if request.user.has_perm('virtualization:change_cluster'):
if request.user.has_perm('virtualization.change_cluster'):
device_table.columns.show('pk')
return render(request, 'virtualization/cluster.html', {
@ -160,6 +160,7 @@ class ClusterBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'virtualization.delete_cluster'
cls = Cluster
queryset = Cluster.objects.all()
filter = filters.ClusterFilter
table = tables.ClusterTable
default_return_url = 'virtualization:cluster_list'