Kaynağa Gözat

Merge branch 'develop' into develop-2.5 (v2.4.4 release)

Jeremy Stretch 7 yıl önce
ebeveyn
işleme
2ac60bdf48

+ 10 - 1
CHANGELOG.md

@@ -19,16 +19,25 @@ v2.5.0 (FUTURE)
 
 
 ---
 ---
 
 
-v2.4.4 (FUTURE)
+v2.4.4 (2018-08-22)
 
 
 ## Enhancements
 ## Enhancements
 
 
+* [#2168](https://github.com/digitalocean/netbox/issues/2168) - Added Extreme SummitStack interface form factors
 * [#2356](https://github.com/digitalocean/netbox/issues/2356) - Include cluster site as read-only field in VirtualMachine serializer
 * [#2356](https://github.com/digitalocean/netbox/issues/2356) - Include cluster site as read-only field in VirtualMachine serializer
 * [#2362](https://github.com/digitalocean/netbox/issues/2362) - Implemented custom admin site to properly handle BASE_PATH
 * [#2362](https://github.com/digitalocean/netbox/issues/2362) - Implemented custom admin site to properly handle BASE_PATH
+* [#2254](https://github.com/digitalocean/netbox/issues/2254) - Implemented searchability for Rack Groups
 
 
 ## Bug Fixes
 ## Bug Fixes
 
 
+* [#2353](https://github.com/digitalocean/netbox/issues/2353) - Handle `DoesNotExist` exception when deleting a device with connected interfaces
+* [#2354](https://github.com/digitalocean/netbox/issues/2354) - Increased maximum MTU for interfaces to 65536 bytes
 * [#2355](https://github.com/digitalocean/netbox/issues/2355) - Added item count to inventory tab on device view
 * [#2355](https://github.com/digitalocean/netbox/issues/2355) - Added item count to inventory tab on device view
+* [#2368](https://github.com/digitalocean/netbox/issues/2368) - Record change in device changelog when altering cluster assignment
+* [#2369](https://github.com/digitalocean/netbox/issues/2369) - Corrected time zone validation on site API serializer
+* [#2370](https://github.com/digitalocean/netbox/issues/2370) - Redirect to parent device after deleting device bays
+* [#2374](https://github.com/digitalocean/netbox/issues/2374) - Fix toggling display of IP addresses in virtual machine interfaces list
+* [#2378](https://github.com/digitalocean/netbox/issues/2378) - Corrected "edit" link for virtual machine interfaces
 
 
 ---
 ---
 
 

+ 1 - 1
docs/configuration/index.md

@@ -12,5 +12,5 @@ While NetBox has many configuration settings, only a few of them must be defined
 Configuration settings may be changed at any time. However, the NetBox service must be restarted before the changes will take effect:
 Configuration settings may be changed at any time. However, the NetBox service must be restarted before the changes will take effect:
 
 
 ```no-highlight
 ```no-highlight
-# sudo supervsiorctl restart netbox
+# sudo supervisorctl restart netbox
 ```
 ```

+ 2 - 2
docs/development/release-checklist.md

@@ -48,9 +48,9 @@ Close the release milestone on GitHub. Ensure that there are no remaining open i
 
 
 Ensure that continuous integration testing on the `develop` branch is completing successfully.
 Ensure that continuous integration testing on the `develop` branch is completing successfully.
 
 
-## Update VERSION
+## Update Version and Changelog
 
 
-Update the `VERSION` constant in `settings.py` to the new release.
+Update the `VERSION` constant in `settings.py` to the new release version and add the current date to the release notes in `CHANGELOG.md`.
 
 
 ## Submit a Pull Request
 ## Submit a Pull Request
 
 

+ 1 - 1
docs/installation/3-http-daemon.md

@@ -56,7 +56,7 @@ To enable SSL, consider this guide on [securing nginx with Let's Encrypt](https:
 ## Option B: Apache
 ## Option B: Apache
 
 
 ```no-highlight
 ```no-highlight
-# apt-get install -y apache2
+# apt-get install -y apache2 libapache2-mod-wsgi-py3
 ```
 ```
 
 
 Once Apache is installed, proceed with the following configuration (Be sure to modify the `ServerName` appropriately):
 Once Apache is installed, proceed with the following configuration (Be sure to modify the `ServerName` appropriately):

+ 9 - 0
netbox/dcim/constants.py

@@ -91,6 +91,11 @@ IFACE_FF_STACKWISE_PLUS = 5050
 IFACE_FF_FLEXSTACK = 5100
 IFACE_FF_FLEXSTACK = 5100
 IFACE_FF_FLEXSTACK_PLUS = 5150
 IFACE_FF_FLEXSTACK_PLUS = 5150
 IFACE_FF_JUNIPER_VCP = 5200
 IFACE_FF_JUNIPER_VCP = 5200
+IFACE_FF_SUMMITSTACK = 5300
+IFACE_FF_SUMMITSTACK128 = 5310
+IFACE_FF_SUMMITSTACK256 = 5320
+IFACE_FF_SUMMITSTACK512 = 5330
+
 # Other
 # Other
 IFACE_FF_OTHER = 32767
 IFACE_FF_OTHER = 32767
 
 
@@ -166,6 +171,10 @@ IFACE_FF_CHOICES = [
             [IFACE_FF_FLEXSTACK, 'Cisco FlexStack'],
             [IFACE_FF_FLEXSTACK, 'Cisco FlexStack'],
             [IFACE_FF_FLEXSTACK_PLUS, 'Cisco FlexStack Plus'],
             [IFACE_FF_FLEXSTACK_PLUS, 'Cisco FlexStack Plus'],
             [IFACE_FF_JUNIPER_VCP, 'Juniper VCP'],
             [IFACE_FF_JUNIPER_VCP, 'Juniper VCP'],
+            [IFACE_FF_SUMMITSTACK, 'Extreme SummitStack'],
+            [IFACE_FF_SUMMITSTACK128, 'Extreme SummitStack-128'],
+            [IFACE_FF_SUMMITSTACK256, 'Extreme SummitStack-256'],
+            [IFACE_FF_SUMMITSTACK512, 'Extreme SummitStack-512'],
         ]
         ]
     ],
     ],
     [
     [

+ 13 - 0
netbox/dcim/filters.py

@@ -110,6 +110,10 @@ class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet):
 
 
 
 
 class RackGroupFilter(django_filters.FilterSet):
 class RackGroupFilter(django_filters.FilterSet):
+    q = django_filters.CharFilter(
+        method='search',
+        label='Search',
+    )
     site_id = django_filters.ModelMultipleChoiceFilter(
     site_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         label='Site (ID)',
         label='Site (ID)',
@@ -125,6 +129,15 @@ class RackGroupFilter(django_filters.FilterSet):
         model = RackGroup
         model = RackGroup
         fields = ['site_id', 'name', 'slug']
         fields = ['site_id', 'name', 'slug']
 
 
+    def search(self, queryset, name, value):
+        if not value.strip():
+            return queryset
+        qs_filter = (
+            Q(name__icontains=value) |
+            Q(slug__icontains=value)
+        )
+        return queryset.filter(qs_filter)
+
 
 
 class RackRoleFilter(django_filters.FilterSet):
 class RackRoleFilter(django_filters.FilterSet):
 
 

+ 29 - 0
netbox/dcim/migrations/0062_interface_mtu.py

@@ -0,0 +1,29 @@
+# Generated by Django 2.0.8 on 2018-08-22 14:23
+
+import django.core.validators
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0061_platform_napalm_args'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='interface',
+            name='mtu',
+            field=models.PositiveIntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(65536)], verbose_name='MTU'),
+        ),
+        migrations.AlterField(
+            model_name='interface',
+            name='form_factor',
+            field=models.PositiveSmallIntegerField(choices=[['Virtual interfaces', [[0, 'Virtual'], [200, 'Link Aggregation Group (LAG)']]], ['Ethernet (fixed)', [[800, '100BASE-TX (10/100ME)'], [1000, '1000BASE-T (1GE)'], [1150, '10GBASE-T (10GE)'], [1170, '10GBASE-CX4 (10GE)']]], ['Ethernet (modular)', [[1050, 'GBIC (1GE)'], [1100, 'SFP (1GE)'], [1200, 'SFP+ (10GE)'], [1300, 'XFP (10GE)'], [1310, 'XENPAK (10GE)'], [1320, 'X2 (10GE)'], [1350, 'SFP28 (25GE)'], [1400, 'QSFP+ (40GE)'], [1500, 'CFP (100GE)'], [1510, 'CFP2 (100GE)'], [1520, 'CFP4 (100GE)'], [1550, 'Cisco CPAK (100GE)'], [1600, 'QSFP28 (100GE)']]], ['Wireless', [[2600, 'IEEE 802.11a'], [2610, 'IEEE 802.11b/g'], [2620, 'IEEE 802.11n'], [2630, 'IEEE 802.11ac'], [2640, 'IEEE 802.11ad']]], ['FibreChannel', [[3010, 'SFP (1GFC)'], [3020, 'SFP (2GFC)'], [3040, 'SFP (4GFC)'], [3080, 'SFP+ (8GFC)'], [3160, 'SFP+ (16GFC)']]], ['Serial', [[4000, 'T1 (1.544 Mbps)'], [4010, 'E1 (2.048 Mbps)'], [4040, 'T3 (45 Mbps)'], [4050, 'E3 (34 Mbps)']]], ['Stacking', [[5000, 'Cisco StackWise'], [5050, 'Cisco StackWise Plus'], [5100, 'Cisco FlexStack'], [5150, 'Cisco FlexStack Plus'], [5200, 'Juniper VCP'], [5300, 'Extreme SummitStack'], [5310, 'Extreme SummitStack-128'], [5320, 'Extreme SummitStack-256'], [5330, 'Extreme SummitStack-512']]], ['Other', [[32767, 'Other']]]], default=1200),
+        ),
+        migrations.AlterField(
+            model_name='interfacetemplate',
+            name='form_factor',
+            field=models.PositiveSmallIntegerField(choices=[['Virtual interfaces', [[0, 'Virtual'], [200, 'Link Aggregation Group (LAG)']]], ['Ethernet (fixed)', [[800, '100BASE-TX (10/100ME)'], [1000, '1000BASE-T (1GE)'], [1150, '10GBASE-T (10GE)'], [1170, '10GBASE-CX4 (10GE)']]], ['Ethernet (modular)', [[1050, 'GBIC (1GE)'], [1100, 'SFP (1GE)'], [1200, 'SFP+ (10GE)'], [1300, 'XFP (10GE)'], [1310, 'XENPAK (10GE)'], [1320, 'X2 (10GE)'], [1350, 'SFP28 (25GE)'], [1400, 'QSFP+ (40GE)'], [1500, 'CFP (100GE)'], [1510, 'CFP2 (100GE)'], [1520, 'CFP4 (100GE)'], [1550, 'Cisco CPAK (100GE)'], [1600, 'QSFP28 (100GE)']]], ['Wireless', [[2600, 'IEEE 802.11a'], [2610, 'IEEE 802.11b/g'], [2620, 'IEEE 802.11n'], [2630, 'IEEE 802.11ac'], [2640, 'IEEE 802.11ad']]], ['FibreChannel', [[3010, 'SFP (1GFC)'], [3020, 'SFP (2GFC)'], [3040, 'SFP (4GFC)'], [3080, 'SFP+ (8GFC)'], [3160, 'SFP+ (16GFC)']]], ['Serial', [[4000, 'T1 (1.544 Mbps)'], [4010, 'E1 (2.048 Mbps)'], [4040, 'T3 (45 Mbps)'], [4050, 'E3 (34 Mbps)']]], ['Stacking', [[5000, 'Cisco StackWise'], [5050, 'Cisco StackWise Plus'], [5100, 'Cisco FlexStack'], [5150, 'Cisco FlexStack Plus'], [5200, 'Juniper VCP'], [5300, 'Extreme SummitStack'], [5310, 'Extreme SummitStack-128'], [5320, 'Extreme SummitStack-256'], [5330, 'Extreme SummitStack-512']]], ['Other', [[32767, 'Other']]]], default=1200),
+        ),
+    ]

+ 2 - 2
netbox/dcim/migrations/0062_remove_platform_rpc_client.py → netbox/dcim/migrations/0063_remove_platform_rpc_client.py

@@ -1,4 +1,4 @@
-# Generated by Django 2.0.8 on 2018-08-16 16:17
+# Generated by Django 2.0.8 on 2018-08-22 16:09
 
 
 from django.db import migrations
 from django.db import migrations
 
 
@@ -6,7 +6,7 @@ from django.db import migrations
 class Migration(migrations.Migration):
 class Migration(migrations.Migration):
 
 
     dependencies = [
     dependencies = [
-        ('dcim', '0061_platform_napalm_args'),
+        ('dcim', '0062_interface_mtu'),
     ]
     ]
 
 
     operations = [
     operations = [

+ 10 - 2
netbox/dcim/models.py

@@ -1754,9 +1754,10 @@ class Interface(ComponentModel):
         blank=True,
         blank=True,
         verbose_name='MAC Address'
         verbose_name='MAC Address'
     )
     )
-    mtu = models.PositiveSmallIntegerField(
+    mtu = models.PositiveIntegerField(
         blank=True,
         blank=True,
         null=True,
         null=True,
+        validators=[MinValueValidator(1), MaxValueValidator(65536)],
         verbose_name='MTU'
         verbose_name='MTU'
     )
     )
     mgmt_only = models.BooleanField(
     mgmt_only = models.BooleanField(
@@ -2012,6 +2013,7 @@ class InterfaceConnection(models.Model):
             (self.interface_a, self.interface_b),
             (self.interface_a, self.interface_b),
             (self.interface_b, self.interface_a),
             (self.interface_b, self.interface_a),
         )
         )
+
         for interface, peer_interface in interfaces:
         for interface, peer_interface in interfaces:
             if action == OBJECTCHANGE_ACTION_DELETE:
             if action == OBJECTCHANGE_ACTION_DELETE:
                 connection_data = {
                 connection_data = {
@@ -2022,11 +2024,17 @@ class InterfaceConnection(models.Model):
                     'connected_interface': peer_interface.pk,
                     'connected_interface': peer_interface.pk,
                     'connection_status': self.connection_status
                     'connection_status': self.connection_status
                 }
                 }
+
+            try:
+                parent_obj = interface.parent
+            except ObjectDoesNotExist:
+                parent_obj = None
+
             ObjectChange(
             ObjectChange(
                 user=user,
                 user=user,
                 request_id=request_id,
                 request_id=request_id,
                 changed_object=interface,
                 changed_object=interface,
-                related_object=interface.parent,
+                related_object=parent_obj,
                 action=OBJECTCHANGE_ACTION_UPDATE,
                 action=OBJECTCHANGE_ACTION_UPDATE,
                 object_data=serialize_object(interface, extra=connection_data)
                 object_data=serialize_object(interface, extra=connection_data)
             ).save()
             ).save()

+ 1 - 0
netbox/netbox/forms.py

@@ -11,6 +11,7 @@ OBJ_TYPE_CHOICES = (
     ('DCIM', (
     ('DCIM', (
         ('site', 'Sites'),
         ('site', 'Sites'),
         ('rack', 'Racks'),
         ('rack', 'Racks'),
+        ('rackgroup', 'Rack Groups'),
         ('devicetype', 'Device types'),
         ('devicetype', 'Device types'),
         ('device', 'Devices'),
         ('device', 'Devices'),
         ('virtualchassis', 'Virtual Chassis'),
         ('virtualchassis', 'Virtual Chassis'),

+ 1 - 1
netbox/netbox/settings.py

@@ -21,7 +21,7 @@ except ImportError:
         "Configuration file is not present. Please define netbox/netbox/configuration.py per the documentation."
         "Configuration file is not present. Please define netbox/netbox/configuration.py per the documentation."
     )
     )
 
 
-VERSION = '2.4.4-dev'
+VERSION = '2.5.0-dev'
 
 
 BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
 BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
 
 

+ 16 - 3
netbox/netbox/views.py

@@ -10,9 +10,16 @@ from rest_framework.views import APIView
 from circuits.filters import CircuitFilter, ProviderFilter
 from circuits.filters import CircuitFilter, ProviderFilter
 from circuits.models import Circuit, Provider
 from circuits.models import Circuit, Provider
 from circuits.tables import CircuitTable, ProviderTable
 from circuits.tables import CircuitTable, ProviderTable
-from dcim.filters import DeviceFilter, DeviceTypeFilter, RackFilter, SiteFilter, VirtualChassisFilter
-from dcim.models import ConsolePort, Device, DeviceType, InterfaceConnection, PowerPort, Rack, Site, VirtualChassis
-from dcim.tables import DeviceDetailTable, DeviceTypeTable, RackTable, SiteTable, VirtualChassisTable
+from dcim.filters import (
+    DeviceFilter, DeviceTypeFilter, RackFilter, RackGroupFilter, SiteFilter, VirtualChassisFilter
+)
+from dcim.models import (
+    ConsolePort, Device, DeviceType, InterfaceConnection, PowerPort, Rack, RackGroup, Site,
+    VirtualChassis
+)
+from dcim.tables import (
+    DeviceDetailTable, DeviceTypeTable, RackTable, RackGroupTable, SiteTable, VirtualChassisTable
+)
 from extras.models import ObjectChange, ReportResult, TopologyMap
 from extras.models import ObjectChange, ReportResult, TopologyMap
 from ipam.filters import AggregateFilter, IPAddressFilter, PrefixFilter, VLANFilter, VRFFilter
 from ipam.filters import AggregateFilter, IPAddressFilter, PrefixFilter, VLANFilter, VRFFilter
 from ipam.models import Aggregate, IPAddress, Prefix, VLAN, VRF
 from ipam.models import Aggregate, IPAddress, Prefix, VLAN, VRF
@@ -56,6 +63,12 @@ SEARCH_TYPES = OrderedDict((
         'table': RackTable,
         'table': RackTable,
         'url': 'dcim:rack_list',
         'url': 'dcim:rack_list',
     }),
     }),
+    ('rackgroup', {
+        'queryset': RackGroup.objects.select_related('site').annotate(rack_count=Count('racks')),
+        'filter': RackGroupFilter,
+        'table': RackGroupTable,
+        'url': 'dcim:rackgroup_list',
+    }),
     ('devicetype', {
     ('devicetype', {
         'queryset': DeviceType.objects.select_related('manufacturer').annotate(instance_count=Count('instances')),
         'queryset': DeviceType.objects.select_related('manufacturer').annotate(instance_count=Count('instances')),
         'filter': DeviceTypeFilter,
         'filter': DeviceTypeFilter,

+ 2 - 2
netbox/templates/dcim/device.html

@@ -447,7 +447,7 @@
         <div class="col-md-12">
         <div class="col-md-12">
             {% if device_bays or device.device_type.is_parent_device %}
             {% if device_bays or device.device_type.is_parent_device %}
                 {% if perms.dcim.delete_devicebay %}
                 {% if perms.dcim.delete_devicebay %}
-                    <form method="post" action="{% url 'dcim:devicebay_bulk_delete' pk=device.pk %}">
+                    <form method="post">
                     {% csrf_token %}
                     {% csrf_token %}
                 {% endif %}
                 {% endif %}
                 <div class="panel panel-default">
                 <div class="panel panel-default">
@@ -483,7 +483,7 @@
                             </button>
                             </button>
                         {% endif %}
                         {% endif %}
                         {% if device_bays and perms.dcim.delete_devicebay %}
                         {% if device_bays and perms.dcim.delete_devicebay %}
-                            <button type="submit" class="btn btn-danger btn-xs">
+                            <button type="submit" formaction="{% url 'dcim:devicebay_bulk_delete' pk=device.pk  %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
                                 <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete selected
                                 <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete selected
                             </button>
                             </button>
                         {% endif %}
                         {% endif %}

+ 2 - 2
netbox/templates/dcim/interface.html

@@ -17,12 +17,12 @@
     </div>
     </div>
     <div class="pull-right">
     <div class="pull-right">
         {% if perms.dcim.change_interface %}
         {% if perms.dcim.change_interface %}
-            <a href="{% url 'dcim:interface_edit' pk=interface.pk %}" class="btn btn-warning">
+            <a href="{% if interface.device %}{% url 'dcim:interface_edit' pk=interface.pk %}{% else %}{% url 'virtualization:interface_edit' pk=interface.pk %}{% endif %}" class="btn btn-warning">
                 <span class="fa fa-pencil" aria-hidden="true"></span> Edit this interface
                 <span class="fa fa-pencil" aria-hidden="true"></span> Edit this interface
             </a>
             </a>
         {% endif %}
         {% endif %}
         {% if perms.dcim.delete_interface %}
         {% if perms.dcim.delete_interface %}
-            <a href="{% url 'dcim:interface_delete' pk=interface.pk %}" class="btn btn-danger">
+            <a href="{% if interface.device %}{% url 'dcim:interface_delete' pk=interface.pk %}{% else %}{% url 'virtualization:interface_delete' pk=interface.pk %}{% endif %}" class="btn btn-danger">
                 <span class="fa fa-trash" aria-hidden="true"></span> Delete this interface
                 <span class="fa fa-trash" aria-hidden="true"></span> Delete this interface
             </a>
             </a>
         {% endif %}
         {% endif %}

+ 2 - 2
netbox/templates/virtualization/virtualmachine.html

@@ -315,9 +315,9 @@
 $('button.toggle-ips').click(function() {
 $('button.toggle-ips').click(function() {
     var selected = $(this).attr('selected');
     var selected = $(this).attr('selected');
     if (selected) {
     if (selected) {
-        $('#interfaces_table tr.ipaddress').hide();
+        $('#interfaces_table tr.ipaddresses').hide();
     } else {
     } else {
-        $('#interfaces_table tr.ipaddress').show();
+        $('#interfaces_table tr.ipaddresses').show();
     }
     }
     $(this).attr('selected', !selected);
     $(this).attr('selected', !selected);
     $(this).children('span').toggleClass('glyphicon-check glyphicon-unchecked');
     $(this).children('span').toggleClass('glyphicon-check glyphicon-unchecked');

+ 3 - 4
netbox/utilities/api.py

@@ -106,10 +106,9 @@ class TimeZoneField(Field):
     def to_internal_value(self, data):
     def to_internal_value(self, data):
         if not data:
         if not data:
             return ""
             return ""
-        try:
-            return pytz.timezone(str(data))
-        except pytz.exceptions.UnknownTimeZoneError:
-            raise ValidationError('Invalid time zone "{}"'.format(data))
+        if data not in pytz.common_timezones:
+            raise ValidationError('Unknown time zone "{}" (see pytz.common_timezones for all options)'.format(data))
+        return pytz.timezone(data)
 
 
 
 
 class SerializedPKRelatedField(PrimaryKeyRelatedField):
 class SerializedPKRelatedField(PrimaryKeyRelatedField):

+ 18 - 9
netbox/virtualization/views.py

@@ -1,5 +1,6 @@
 from django.contrib import messages
 from django.contrib import messages
 from django.contrib.auth.mixins import PermissionRequiredMixin
 from django.contrib.auth.mixins import PermissionRequiredMixin
+from django.db import transaction
 from django.db.models import Count
 from django.db.models import Count
 from django.shortcuts import get_object_or_404, redirect, render
 from django.shortcuts import get_object_or_404, redirect, render
 from django.urls import reverse
 from django.urls import reverse
@@ -181,17 +182,21 @@ class ClusterAddDevicesView(PermissionRequiredMixin, View):
 
 
         if form.is_valid():
         if form.is_valid():
 
 
-            # Assign the selected Devices to the Cluster
-            devices = form.cleaned_data['devices']
-            Device.objects.filter(pk__in=devices).update(cluster=cluster)
+            device_pks = form.cleaned_data['devices']
+            with transaction.atomic():
+
+                # Assign the selected Devices to the Cluster
+                for device in Device.objects.filter(pk__in=device_pks):
+                    device.cluster = cluster
+                    device.save()
 
 
             messages.success(request, "Added {} devices to cluster {}".format(
             messages.success(request, "Added {} devices to cluster {}".format(
-                len(devices), cluster
+                len(device_pks), cluster
             ))
             ))
             return redirect(cluster.get_absolute_url())
             return redirect(cluster.get_absolute_url())
 
 
         return render(request, self.template_name, {
         return render(request, self.template_name, {
-            'cluser': cluster,
+            'cluster': cluster,
             'form': form,
             'form': form,
             'return_url': cluster.get_absolute_url(),
             'return_url': cluster.get_absolute_url(),
         })
         })
@@ -210,12 +215,16 @@ class ClusterRemoveDevicesView(PermissionRequiredMixin, View):
             form = self.form(request.POST)
             form = self.form(request.POST)
             if form.is_valid():
             if form.is_valid():
 
 
-                # Remove the selected Devices from the Cluster
-                devices = form.cleaned_data['pk']
-                Device.objects.filter(pk__in=devices).update(cluster=None)
+                device_pks = form.cleaned_data['pk']
+                with transaction.atomic():
+
+                    # Remove the selected Devices from the Cluster
+                    for device in Device.objects.filter(pk__in=device_pks):
+                        device.cluster = None
+                        device.save()
 
 
                 messages.success(request, "Removed {} devices from cluster {}".format(
                 messages.success(request, "Removed {} devices from cluster {}".format(
-                    len(devices), cluster
+                    len(device_pks), cluster
                 ))
                 ))
                 return redirect(cluster.get_absolute_url())
                 return redirect(cluster.get_absolute_url())