From 2ca161f3d833ab9d76329c6923b9cbe70471f293 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 22 Sep 2017 12:53:09 -0400 Subject: [PATCH] Closes #1509: Extended cluster model to allow site assignment --- netbox/dcim/models.py | 6 +++ netbox/templates/virtualization/cluster.html | 10 +++++ netbox/virtualization/api/serializers.py | 7 +-- netbox/virtualization/filters.py | 12 ++++- netbox/virtualization/forms.py | 45 ++++++++++++++++--- .../migrations/0003_cluster_add_site.py | 22 +++++++++ netbox/virtualization/models.py | 23 +++++++++- netbox/virtualization/tables.py | 2 +- netbox/virtualization/views.py | 4 +- 9 files changed, 117 insertions(+), 14 deletions(-) create mode 100644 netbox/virtualization/migrations/0003_cluster_add_site.py 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():