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:
@ -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)
|
||||
|
@ -66,6 +66,16 @@
|
||||
{% endif %}
|
||||
</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>
|
||||
|
@ -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']
|
||||
|
||||
|
||||
#
|
||||
|
@ -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
|
||||
|
@ -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
netbox/virtualization/migrations/0003_cluster_add_site.py
Normal file
22
netbox/virtualization/migrations/0003_cluster_add_site.py
Normal 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'),
|
||||
),
|
||||
]
|
@ -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,
|
||||
|
@ -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')
|
||||
|
||||
|
||||
#
|
||||
|
@ -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():
|
||||
|
||||
|
Reference in New Issue
Block a user