Browse Source

Closes #1509: Extended cluster model to allow site assignment

Jeremy Stretch 7 years ago
parent
commit
2ca161f3d8

+ 6 - 0
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),
                 '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):
     def save(self, *args, **kwargs):
 
 
         is_new = not bool(self.pk)
         is_new = not bool(self.pk)

+ 10 - 0
netbox/templates/virtualization/cluster.html

@@ -67,6 +67,16 @@
                     </td>
                     </td>
                 </tr>
                 </tr>
                 <tr>
                 <tr>
+                    <td>Site</td>
+                    <td>
+                        {% if cluster.site %}
+                            <a href="{{ cluster.site.get_absolute_url }}">{{ cluster.site }}</a>
+                        {% else %}
+                            <span class="text-muted">None</span>
+                        {% endif %}
+                    </td>
+                </tr>
+                <tr>
                     <td>Virtual Machines</td>
                     <td>Virtual Machines</td>
                     <td><a href="{% url 'virtualization:virtualmachine_list' %}?cluster_id={{ cluster.pk }}">{{ cluster.virtual_machines.count }}</a></td>
                     <td><a href="{% url 'virtualization:virtualmachine_list' %}?cluster_id={{ cluster.pk }}">{{ cluster.virtual_machines.count }}</a></td>
                 </tr>
                 </tr>

+ 4 - 3
netbox/virtualization/api/serializers.py

@@ -2,7 +2,7 @@ from __future__ import unicode_literals
 
 
 from rest_framework import serializers
 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.constants import VIFACE_FF_CHOICES
 from dcim.models import Interface
 from dcim.models import Interface
 from extras.api.customfields import CustomFieldModelSerializer
 from extras.api.customfields import CustomFieldModelSerializer
@@ -57,10 +57,11 @@ class NestedClusterGroupSerializer(serializers.ModelSerializer):
 class ClusterSerializer(CustomFieldModelSerializer):
 class ClusterSerializer(CustomFieldModelSerializer):
     type = NestedClusterTypeSerializer()
     type = NestedClusterTypeSerializer()
     group = NestedClusterGroupSerializer()
     group = NestedClusterGroupSerializer()
+    site = NestedSiteSerializer()
 
 
     class Meta:
     class Meta:
         model = Cluster
         model = Cluster
-        fields = ['id', 'name', 'type', 'group', 'comments', 'custom_fields']
+        fields = ['id', 'name', 'type', 'group', 'site', 'comments', 'custom_fields']
 
 
 
 
 class NestedClusterSerializer(serializers.ModelSerializer):
 class NestedClusterSerializer(serializers.ModelSerializer):
@@ -75,7 +76,7 @@ class WritableClusterSerializer(CustomFieldModelSerializer):
 
 
     class Meta:
     class Meta:
         model = Cluster
         model = Cluster
-        fields = ['id', 'name', 'type', 'group', 'comments', 'custom_fields']
+        fields = ['id', 'name', 'type', 'group', 'site', 'comments', 'custom_fields']
 
 
 
 
 #
 #

+ 11 - 1
netbox/virtualization/filters.py

@@ -3,7 +3,7 @@ from __future__ import unicode_literals
 import django_filters
 import django_filters
 from django.db.models import Q
 from django.db.models import Q
 
 
-from dcim.models import Platform
+from dcim.models import Platform, Site
 from extras.filters import CustomFieldFilterSet
 from extras.filters import CustomFieldFilterSet
 from tenancy.models import Tenant
 from tenancy.models import Tenant
 from utilities.filters import NullableModelMultipleChoiceFilter, NumericInFilter
 from utilities.filters import NullableModelMultipleChoiceFilter, NumericInFilter
@@ -36,6 +36,16 @@ class ClusterFilter(CustomFieldFilterSet):
         to_field_name='slug',
         to_field_name='slug',
         label='Cluster type (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:
     class Meta:
         model = Cluster
         model = Cluster

+ 39 - 6
netbox/virtualization/forms.py

@@ -3,6 +3,7 @@ from __future__ import unicode_literals
 from mptt.forms import TreeNodeChoiceField
 from mptt.forms import TreeNodeChoiceField
 
 
 from django import forms
 from django import forms
+from django.core.exceptions import ValidationError
 from django.db.models import Count
 from django.db.models import Count
 
 
 from dcim.constants import IFACE_FF_VIRTUAL, VIFACE_FF_CHOICES
 from dcim.constants import IFACE_FF_VIRTUAL, VIFACE_FF_CHOICES
@@ -53,7 +54,7 @@ class ClusterForm(BootstrapMixin, CustomFieldForm):
 
 
     class Meta:
     class Meta:
         model = Cluster
         model = Cluster
-        fields = ['name', 'type', 'group', 'comments']
+        fields = ['name', 'type', 'group', 'site', 'comments']
 
 
 
 
 class ClusterCSVForm(forms.ModelForm):
 class ClusterCSVForm(forms.ModelForm):
@@ -74,34 +75,50 @@ class ClusterCSVForm(forms.ModelForm):
             'invalid_choice': 'Invalid cluster group name.',
             '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:
     class Meta:
         model = Cluster
         model = Cluster
-        fields = ['name', 'type', 'group', 'comments']
+        fields = ['name', 'type', 'group', 'site', 'comments']
 
 
 
 
 class ClusterBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
 class ClusterBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
     pk = forms.ModelMultipleChoiceField(queryset=Cluster.objects.all(), widget=forms.MultipleHiddenInput)
     pk = forms.ModelMultipleChoiceField(queryset=Cluster.objects.all(), widget=forms.MultipleHiddenInput)
     type = forms.ModelChoiceField(queryset=ClusterType.objects.all(), required=False)
     type = forms.ModelChoiceField(queryset=ClusterType.objects.all(), required=False)
     group = forms.ModelChoiceField(queryset=ClusterGroup.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)
     comments = CommentField(widget=SmallTextarea)
 
 
     class Meta:
     class Meta:
-        nullable_fields = ['group', 'comments']
+        nullable_fields = ['group', 'site', 'comments']
 
 
 
 
 class ClusterFilterForm(BootstrapMixin, CustomFieldFilterForm):
 class ClusterFilterForm(BootstrapMixin, CustomFieldFilterForm):
     model = Cluster
     model = Cluster
     q = forms.CharField(required=False, label='Search')
     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(
     group = FilterChoiceField(
         queryset=ClusterGroup.objects.annotate(filter_count=Count('clusters')),
         queryset=ClusterGroup.objects.annotate(filter_count=Count('clusters')),
         to_field_name='slug',
         to_field_name='slug',
         null_option=(0, 'None'),
         null_option=(0, 'None'),
         required=False,
         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',
         to_field_name='slug',
+        null_option=(0, 'None'),
         required=False,
         required=False,
     )
     )
 
 
@@ -153,12 +170,28 @@ class ClusterAddDevicesForm(BootstrapMixin, ChainedFieldsMixin, forms.Form):
     class Meta:
     class Meta:
         fields = ['region', 'site', 'rack', 'devices']
         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)
         super(ClusterAddDevicesForm, self).__init__(*args, **kwargs)
 
 
         self.fields['devices'].choices = []
         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):
 class ClusterRemoveDevicesForm(ConfirmationForm):
     pk = forms.ModelMultipleChoiceField(queryset=Device.objects.all(), widget=forms.MultipleHiddenInput)
     pk = forms.ModelMultipleChoiceField(queryset=Device.objects.all(), widget=forms.MultipleHiddenInput)

+ 22 - 0
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'),
+        ),
+    ]

+ 22 - 1
netbox/virtualization/models.py

@@ -1,10 +1,12 @@
 from __future__ import unicode_literals
 from __future__ import unicode_literals
 
 
 from django.contrib.contenttypes.fields import GenericRelation
 from django.contrib.contenttypes.fields import GenericRelation
+from django.core.exceptions import ValidationError
 from django.db import models
 from django.db import models
 from django.urls import reverse
 from django.urls import reverse
 from django.utils.encoding import python_2_unicode_compatible
 from django.utils.encoding import python_2_unicode_compatible
 
 
+from dcim.models import Device
 from extras.models import CustomFieldModel, CustomFieldValue
 from extras.models import CustomFieldModel, CustomFieldValue
 from utilities.models import CreatedUpdatedModel
 from utilities.models import CreatedUpdatedModel
 from utilities.utils import csv_format
 from utilities.utils import csv_format
@@ -90,6 +92,13 @@ class Cluster(CreatedUpdatedModel, CustomFieldModel):
         blank=True,
         blank=True,
         null=True
         null=True
     )
     )
+    site = models.ForeignKey(
+        to='dcim.Site',
+        on_delete=models.PROTECT,
+        related_name='clusters',
+        blank=True,
+        null=True
+    )
     comments = models.TextField(
     comments = models.TextField(
         blank=True
         blank=True
     )
     )
@@ -100,7 +109,7 @@ class Cluster(CreatedUpdatedModel, CustomFieldModel):
     )
     )
 
 
     csv_headers = [
     csv_headers = [
-        'name', 'type', 'group', 'comments',
+        'name', 'type', 'group', 'site', 'comments',
     ]
     ]
 
 
     class Meta:
     class Meta:
@@ -112,6 +121,18 @@ class Cluster(CreatedUpdatedModel, CustomFieldModel):
     def get_absolute_url(self):
     def get_absolute_url(self):
         return reverse('virtualization:cluster', args=[self.pk])
         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):
     def to_csv(self):
         return csv_format([
         return csv_format([
             self.name,
             self.name,

+ 1 - 1
netbox/virtualization/tables.py

@@ -79,7 +79,7 @@ class ClusterTable(BaseTable):
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = Cluster
         model = Cluster
-        fields = ('pk', 'name', 'type', 'group', 'device_count', 'vm_count')
+        fields = ('pk', 'name', 'type', 'group', 'site', 'device_count', 'vm_count')
 
 
 
 
 #
 #

+ 2 - 2
netbox/virtualization/views.py

@@ -166,7 +166,7 @@ class ClusterAddDevicesView(PermissionRequiredMixin, View):
     def get(self, request, pk):
     def get(self, request, pk):
 
 
         cluster = get_object_or_404(Cluster, pk=pk)
         cluster = get_object_or_404(Cluster, pk=pk)
-        form = self.form()
+        form = self.form(cluster)
 
 
         return render(request, self.template_name, {
         return render(request, self.template_name, {
             'cluster': cluster,
             'cluster': cluster,
@@ -177,7 +177,7 @@ class ClusterAddDevicesView(PermissionRequiredMixin, View):
     def post(self, request, pk):
     def post(self, request, pk):
 
 
         cluster = get_object_or_404(Cluster, pk=pk)
         cluster = get_object_or_404(Cluster, pk=pk)
-        form = self.form(request.POST)
+        form = self.form(cluster, request.POST)
 
 
         if form.is_valid():
         if form.is_valid():