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),
|
'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)
|
||||||
|
@ -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>
|
||||||
|
@ -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']
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
|
@ -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
|
||||||
|
@ -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)
|
||||||
|
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 __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,
|
||||||
|
@ -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')
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
|
@ -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():
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user