Parcourir la source

Closes #1509: Extended cluster model to allow site assignment

Jeremy Stretch il y a 7 ans
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),
             })
 
+        # 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)

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

@@ -67,6 +67,16 @@
                     </td>
                 </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><a href="{% url 'virtualization:virtualmachine_list' %}?cluster_id={{ cluster.pk }}">{{ cluster.virtual_machines.count }}</a></td>
                 </tr>

+ 4 - 3
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']
 
 
 #

+ 11 - 1
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

+ 39 - 6
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)

+ 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 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,

+ 1 - 1
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')
 
 
 #

+ 2 - 2
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():