diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py
index b6c726863..73ec6c622 100644
--- a/netbox/dcim/models.py
+++ b/netbox/dcim/models.py
@@ -921,6 +921,12 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
'primary_ip6': "The specified IP address ({}) is not assigned to this device.".format(self.primary_ip6),
})
+ # A Device can only be assigned to a Cluster in the same Site (or no Site)
+ if self.cluster and self.cluster.site is not None and self.cluster.site != self.site:
+ raise ValidationError({
+ 'cluster': "The assigned cluster belongs to a different site ({})".format(self.cluster.site)
+ })
+
def save(self, *args, **kwargs):
is_new = not bool(self.pk)
diff --git a/netbox/templates/virtualization/cluster.html b/netbox/templates/virtualization/cluster.html
index 6d7d43524..08251e2fa 100644
--- a/netbox/templates/virtualization/cluster.html
+++ b/netbox/templates/virtualization/cluster.html
@@ -66,6 +66,16 @@
{% endif %}
+
+ Site |
+
+ {% if cluster.site %}
+ {{ cluster.site }}
+ {% else %}
+ None
+ {% endif %}
+ |
+
Virtual Machines |
{{ cluster.virtual_machines.count }} |
diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py
index fd0ace6a0..b85495d83 100644
--- a/netbox/virtualization/api/serializers.py
+++ b/netbox/virtualization/api/serializers.py
@@ -2,7 +2,7 @@ from __future__ import unicode_literals
from rest_framework import serializers
-from dcim.api.serializers import NestedPlatformSerializer
+from dcim.api.serializers import NestedPlatformSerializer, NestedSiteSerializer
from dcim.constants import VIFACE_FF_CHOICES
from dcim.models import Interface
from extras.api.customfields import CustomFieldModelSerializer
@@ -57,10 +57,11 @@ class NestedClusterGroupSerializer(serializers.ModelSerializer):
class ClusterSerializer(CustomFieldModelSerializer):
type = NestedClusterTypeSerializer()
group = NestedClusterGroupSerializer()
+ site = NestedSiteSerializer()
class Meta:
model = Cluster
- fields = ['id', 'name', 'type', 'group', 'comments', 'custom_fields']
+ fields = ['id', 'name', 'type', 'group', 'site', 'comments', 'custom_fields']
class NestedClusterSerializer(serializers.ModelSerializer):
@@ -75,7 +76,7 @@ class WritableClusterSerializer(CustomFieldModelSerializer):
class Meta:
model = Cluster
- fields = ['id', 'name', 'type', 'group', 'comments', 'custom_fields']
+ fields = ['id', 'name', 'type', 'group', 'site', 'comments', 'custom_fields']
#
diff --git a/netbox/virtualization/filters.py b/netbox/virtualization/filters.py
index 41c5b474b..ea7686a23 100644
--- a/netbox/virtualization/filters.py
+++ b/netbox/virtualization/filters.py
@@ -3,7 +3,7 @@ from __future__ import unicode_literals
import django_filters
from django.db.models import Q
-from dcim.models import Platform
+from dcim.models import Platform, Site
from extras.filters import CustomFieldFilterSet
from tenancy.models import Tenant
from utilities.filters import NullableModelMultipleChoiceFilter, NumericInFilter
@@ -36,6 +36,16 @@ class ClusterFilter(CustomFieldFilterSet):
to_field_name='slug',
label='Cluster type (slug)',
)
+ site_id = django_filters.ModelMultipleChoiceFilter(
+ queryset=Site.objects.all(),
+ label='Site (ID)',
+ )
+ site = django_filters.ModelMultipleChoiceFilter(
+ name='site__slug',
+ queryset=Site.objects.all(),
+ to_field_name='slug',
+ label='Site (slug)',
+ )
class Meta:
model = Cluster
diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py
index ca39a43ce..bcae2f842 100644
--- a/netbox/virtualization/forms.py
+++ b/netbox/virtualization/forms.py
@@ -3,6 +3,7 @@ from __future__ import unicode_literals
from mptt.forms import TreeNodeChoiceField
from django import forms
+from django.core.exceptions import ValidationError
from django.db.models import Count
from dcim.constants import IFACE_FF_VIRTUAL, VIFACE_FF_CHOICES
@@ -53,7 +54,7 @@ class ClusterForm(BootstrapMixin, CustomFieldForm):
class Meta:
model = Cluster
- fields = ['name', 'type', 'group', 'comments']
+ fields = ['name', 'type', 'group', 'site', 'comments']
class ClusterCSVForm(forms.ModelForm):
@@ -74,34 +75,50 @@ class ClusterCSVForm(forms.ModelForm):
'invalid_choice': 'Invalid cluster group name.',
}
)
+ site = forms.ModelChoiceField(
+ queryset=Site.objects.all(),
+ to_field_name='name',
+ required=False,
+ help_text='Name of assigned site',
+ error_messages={
+ 'invalid_choice': 'Invalid site name.',
+ }
+ )
class Meta:
model = Cluster
- fields = ['name', 'type', 'group', 'comments']
+ fields = ['name', 'type', 'group', 'site', 'comments']
class ClusterBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=Cluster.objects.all(), widget=forms.MultipleHiddenInput)
type = forms.ModelChoiceField(queryset=ClusterType.objects.all(), required=False)
group = forms.ModelChoiceField(queryset=ClusterGroup.objects.all(), required=False)
+ site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False)
comments = CommentField(widget=SmallTextarea)
class Meta:
- nullable_fields = ['group', 'comments']
+ nullable_fields = ['group', 'site', 'comments']
class ClusterFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = Cluster
q = forms.CharField(required=False, label='Search')
+ type = FilterChoiceField(
+ queryset=ClusterType.objects.annotate(filter_count=Count('clusters')),
+ to_field_name='slug',
+ required=False,
+ )
group = FilterChoiceField(
queryset=ClusterGroup.objects.annotate(filter_count=Count('clusters')),
to_field_name='slug',
null_option=(0, 'None'),
required=False,
)
- type = FilterChoiceField(
- queryset=ClusterType.objects.annotate(filter_count=Count('clusters')),
+ site = FilterChoiceField(
+ queryset=Site.objects.annotate(filter_count=Count('clusters')),
to_field_name='slug',
+ null_option=(0, 'None'),
required=False,
)
@@ -153,12 +170,28 @@ class ClusterAddDevicesForm(BootstrapMixin, ChainedFieldsMixin, forms.Form):
class Meta:
fields = ['region', 'site', 'rack', 'devices']
- def __init__(self, *args, **kwargs):
+ def __init__(self, cluster, *args, **kwargs):
+
+ self.cluster = cluster
super(ClusterAddDevicesForm, self).__init__(*args, **kwargs)
self.fields['devices'].choices = []
+ def clean(self):
+
+ super(ClusterAddDevicesForm, self).clean()
+
+ # If the Cluster is assigned to a Site, all Devices must be assigned to that Site.
+ if self.cluster.site is not None:
+ for device in self.cleaned_data.get('devices'):
+ if device.site != self.cluster.site:
+ raise ValidationError({
+ 'devices': "{} belongs to a different site ({}) than the cluster ({})".format(
+ device, device.site, self.cluster.site
+ )
+ })
+
class ClusterRemoveDevicesForm(ConfirmationForm):
pk = forms.ModelMultipleChoiceField(queryset=Device.objects.all(), widget=forms.MultipleHiddenInput)
diff --git a/netbox/virtualization/migrations/0003_cluster_add_site.py b/netbox/virtualization/migrations/0003_cluster_add_site.py
new file mode 100644
index 000000000..5ac3c578b
--- /dev/null
+++ b/netbox/virtualization/migrations/0003_cluster_add_site.py
@@ -0,0 +1,22 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.4 on 2017-09-22 16:30
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('dcim', '0044_virtualization'),
+ ('virtualization', '0002_virtualmachine_add_status'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='cluster',
+ name='site',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='clusters', to='dcim.Site'),
+ ),
+ ]
diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py
index 8dc241335..edf0385d4 100644
--- a/netbox/virtualization/models.py
+++ b/netbox/virtualization/models.py
@@ -1,10 +1,12 @@
from __future__ import unicode_literals
from django.contrib.contenttypes.fields import GenericRelation
+from django.core.exceptions import ValidationError
from django.db import models
from django.urls import reverse
from django.utils.encoding import python_2_unicode_compatible
+from dcim.models import Device
from extras.models import CustomFieldModel, CustomFieldValue
from utilities.models import CreatedUpdatedModel
from utilities.utils import csv_format
@@ -90,6 +92,13 @@ class Cluster(CreatedUpdatedModel, CustomFieldModel):
blank=True,
null=True
)
+ site = models.ForeignKey(
+ to='dcim.Site',
+ on_delete=models.PROTECT,
+ related_name='clusters',
+ blank=True,
+ null=True
+ )
comments = models.TextField(
blank=True
)
@@ -100,7 +109,7 @@ class Cluster(CreatedUpdatedModel, CustomFieldModel):
)
csv_headers = [
- 'name', 'type', 'group', 'comments',
+ 'name', 'type', 'group', 'site', 'comments',
]
class Meta:
@@ -112,6 +121,18 @@ class Cluster(CreatedUpdatedModel, CustomFieldModel):
def get_absolute_url(self):
return reverse('virtualization:cluster', args=[self.pk])
+ def clean(self):
+
+ # If the Cluster is assigned to a Site, verify that all host Devices belong to that Site.
+ if self.pk and self.site:
+ nonsite_devices = Device.objects.filter(cluster=self).exclude(site=self.site).count()
+ if nonsite_devices:
+ raise ValidationError({
+ 'site': "{} devices are assigned as hosts for this cluster but are not in site {}".format(
+ nonsite_devices, self.site
+ )
+ })
+
def to_csv(self):
return csv_format([
self.name,
diff --git a/netbox/virtualization/tables.py b/netbox/virtualization/tables.py
index 7956f898e..21314b51c 100644
--- a/netbox/virtualization/tables.py
+++ b/netbox/virtualization/tables.py
@@ -79,7 +79,7 @@ class ClusterTable(BaseTable):
class Meta(BaseTable.Meta):
model = Cluster
- fields = ('pk', 'name', 'type', 'group', 'device_count', 'vm_count')
+ fields = ('pk', 'name', 'type', 'group', 'site', 'device_count', 'vm_count')
#
diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py
index 0b490db9a..6dcb685d6 100644
--- a/netbox/virtualization/views.py
+++ b/netbox/virtualization/views.py
@@ -166,7 +166,7 @@ class ClusterAddDevicesView(PermissionRequiredMixin, View):
def get(self, request, pk):
cluster = get_object_or_404(Cluster, pk=pk)
- form = self.form()
+ form = self.form(cluster)
return render(request, self.template_name, {
'cluster': cluster,
@@ -177,7 +177,7 @@ class ClusterAddDevicesView(PermissionRequiredMixin, View):
def post(self, request, pk):
cluster = get_object_or_404(Cluster, pk=pk)
- form = self.form(request.POST)
+ form = self.form(cluster, request.POST)
if form.is_valid():