1
0
mirror of https://github.com/netbox-community/netbox.git synced 2024-05-10 07:54:54 +00:00

Closes #1509: Extended cluster model to allow site assignment

This commit is contained in:
Jeremy Stretch
2017-09-22 12:53:09 -04:00
parent 4cfad2ef3b
commit 2ca161f3d8
9 changed files with 117 additions and 14 deletions

View File

@ -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)

View File

@ -66,6 +66,16 @@
{% endif %} {% endif %}
</td> </td>
</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> <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>

View File

@ -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']
# #

View File

@ -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

View File

@ -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( site = FilterChoiceField(
queryset=ClusterType.objects.annotate(filter_count=Count('clusters')), 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)

View File

@ -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'),
),
]

View File

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

View File

@ -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')
# #

View File

@ -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():