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

Merge branch 'feature' into 9102-cabling

This commit is contained in:
jeremystretch
2022-05-26 15:37:29 -04:00
38 changed files with 541 additions and 128 deletions

View File

@@ -84,13 +84,14 @@ class CustomFieldSerializer(ValidatedModelSerializer):
)
filter_logic = ChoiceField(choices=CustomFieldFilterLogicChoices, required=False)
data_type = serializers.SerializerMethodField()
ui_visibility = ChoiceField(choices=CustomFieldVisibilityChoices, required=False)
class Meta:
model = CustomField
fields = [
'id', 'url', 'display', 'content_types', 'type', 'object_type', 'data_type', 'name', 'label', 'group_name',
'description', 'required', 'filter_logic', 'default', 'weight', 'validation_minimum', 'validation_maximum',
'validation_regex', 'choices', 'created', 'last_updated',
'description', 'required', 'filter_logic', 'ui_visibility', 'default', 'weight', 'validation_minimum',
'validation_maximum', 'validation_regex', 'choices', 'created', 'last_updated',
]
def get_data_type(self, obj):

View File

@@ -47,6 +47,19 @@ class CustomFieldFilterLogicChoices(ChoiceSet):
)
class CustomFieldVisibilityChoices(ChoiceSet):
VISIBILITY_READ_WRITE = 'read-write'
VISIBILITY_READ_ONLY = 'read-only'
VISIBILITY_HIDDEN = 'hidden'
CHOICES = (
(VISIBILITY_READ_WRITE, 'Read/Write'),
(VISIBILITY_READ_ONLY, 'Read-only'),
(VISIBILITY_HIDDEN, 'Hidden'),
)
#
# CustomLinks
#

View File

@@ -62,7 +62,10 @@ class CustomFieldFilterSet(BaseFilterSet):
class Meta:
model = CustomField
fields = ['id', 'content_types', 'name', 'group_name', 'required', 'filter_logic', 'weight', 'description']
fields = [
'id', 'content_types', 'name', 'group_name', 'required', 'filter_logic', 'ui_visibility', 'weight',
'description',
]
def search(self, queryset, name, value):
if not value.strip():

View File

@@ -37,6 +37,13 @@ class CustomFieldBulkEditForm(BulkEditForm):
weight = forms.IntegerField(
required=False
)
ui_visibility = forms.ChoiceField(
label="UI visibility",
choices=add_blank_choice(CustomFieldVisibilityChoices),
required=False,
initial='',
widget=StaticSelect()
)
nullable_fields = ('group_name', 'description',)

View File

@@ -38,6 +38,7 @@ class CustomFieldCSVForm(CSVModelForm):
fields = (
'name', 'label', 'group_name', 'type', 'content_types', 'required', 'description', 'weight', 'filter_logic',
'default', 'choices', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex',
'ui_visibility',
)

View File

@@ -1,6 +1,7 @@
from django.contrib.contenttypes.models import ContentType
from extras.models import *
from extras.choices import CustomFieldVisibilityChoices
__all__ = (
'CustomFieldsMixin',
@@ -42,8 +43,18 @@ class CustomFieldsMixin:
Append form fields for all CustomFields assigned to this object type.
"""
for customfield in self._get_custom_fields(self._get_content_type()):
if customfield.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_HIDDEN:
continue
field_name = f'cf_{customfield.name}'
self.fields[field_name] = self._get_form_field(customfield)
if customfield.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_READ_ONLY:
self.fields[field_name].disabled = True
if self.fields[field_name].help_text:
self.fields[field_name].help_text += '<br />'
self.fields[field_name].help_text += '<i class="mdi mdi-alert-circle-outline"></i> ' \
'Field is set to read-only.'
# Annotate the field in the list of CustomField form fields
self.custom_fields[field_name] = customfield

View File

@@ -32,7 +32,7 @@ __all__ = (
class CustomFieldFilterForm(FilterForm):
fieldsets = (
(None, ('q',)),
('Attributes', ('content_types', 'type', 'group_name', 'weight', 'required')),
('Attributes', ('content_types', 'type', 'group_name', 'weight', 'required', 'ui_visibility')),
)
content_types = ContentTypeMultipleChoiceField(
queryset=ContentType.objects.all(),
@@ -56,6 +56,12 @@ class CustomFieldFilterForm(FilterForm):
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
ui_visibility = forms.ChoiceField(
choices=add_blank_choice(CustomFieldVisibilityChoices),
required=False,
label=_('UI visibility'),
widget=StaticSelect()
)
class CustomLinkFilterForm(FilterForm):

View File

@@ -43,7 +43,7 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm):
('Custom Field', (
'content_types', 'name', 'label', 'group_name', 'type', 'object_type', 'weight', 'required', 'description',
)),
('Behavior', ('filter_logic',)),
('Behavior', ('filter_logic', 'ui_visibility')),
('Values', ('default', 'choices')),
('Validation', ('validation_minimum', 'validation_maximum', 'validation_regex')),
)
@@ -58,6 +58,7 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm):
widgets = {
'type': StaticSelect(),
'filter_logic': StaticSelect(),
'ui_visibility': StaticSelect(),
}

View File

@@ -0,0 +1,18 @@
# Generated by Django 4.0.4 on 2022-05-23 20:23
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('extras', '0074_customfield_group_name'),
]
operations = [
migrations.AddField(
model_name='customfield',
name='ui_visibility',
field=models.CharField(default='read-write', max_length=50),
),
]

View File

@@ -136,6 +136,13 @@ class CustomField(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
null=True,
help_text='Comma-separated list of available choices (for selection fields)'
)
ui_visibility = models.CharField(
max_length=50,
choices=CustomFieldVisibilityChoices,
default=CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE,
verbose_name='UI visibility',
help_text='Specifies the visibility of custom field in the UI'
)
objects = CustomFieldManager()
class Meta:

View File

@@ -28,12 +28,13 @@ class CustomFieldTable(NetBoxTable):
)
content_types = columns.ContentTypesColumn()
required = columns.BooleanColumn()
ui_visibility = columns.ChoiceFieldColumn(verbose_name="UI visibility")
class Meta(NetBoxTable.Meta):
model = CustomField
fields = (
'pk', 'id', 'name', 'content_types', 'label', 'type', 'group_name', 'required', 'weight', 'default',
'description', 'filter_logic', 'choices', 'created', 'last_updated',
'description', 'filter_logic', 'ui_visibility', 'choices', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'content_types', 'label', 'group_name', 'type', 'required', 'description')

View File

@@ -36,13 +36,14 @@ class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'default': None,
'weight': 200,
'required': True,
'ui_visibility': CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE,
}
cls.csv_data = (
'name,label,type,content_types,weight,filter_logic,choices,validation_minimum,validation_maximum,validation_regex',
'field4,Field 4,text,dcim.site,100,exact,,,,[a-z]{3}',
'field5,Field 5,integer,dcim.site,100,exact,,1,100,',
'field6,Field 6,select,dcim.site,100,exact,"A,B,C",,,',
'name,label,type,content_types,weight,filter_logic,choices,validation_minimum,validation_maximum,validation_regex,ui_visibility',
'field4,Field 4,text,dcim.site,100,exact,,,,[a-z]{3},read-write',
'field5,Field 5,integer,dcim.site,100,exact,,1,100,,read-write',
'field6,Field 6,select,dcim.site,100,exact,"A,B,C",,,,read-write',
)
cls.bulk_edit_data = {

View File

@@ -9,7 +9,7 @@ from django.core.validators import ValidationError
from django.db import models
from taggit.managers import TaggableManager
from extras.choices import ObjectChangeActionChoices
from extras.choices import CustomFieldVisibilityChoices, ObjectChangeActionChoices
from extras.utils import register_features
from netbox.signals import post_clean
from utilities.utils import serialize_object
@@ -100,7 +100,7 @@ class CustomFieldsMixin(models.Model):
"""
return self.custom_field_data
def get_custom_fields(self):
def get_custom_fields(self, omit_hidden=False):
"""
Return a dictionary of custom fields for a single object in the form `{field: value}`.
@@ -114,6 +114,10 @@ class CustomFieldsMixin(models.Model):
data = {}
for field in CustomField.objects.get_for_model(self):
# Skip fields that are hidden if 'omit_hidden' is set
if omit_hidden and field.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_HIDDEN:
continue
value = self.custom_field_data.get(field.name)
data[field] = field.deserialize(value)
@@ -121,10 +125,10 @@ class CustomFieldsMixin(models.Model):
def get_custom_fields_by_group(self):
"""
Return a dictionary of custom field/value mappings organized by group.
Return a dictionary of custom field/value mappings organized by group. Hidden fields are omitted.
"""
grouped_custom_fields = defaultdict(dict)
for cf, value in self.get_custom_fields().items():
for cf, value in self.get_custom_fields(omit_hidden=True).items():
grouped_custom_fields[cf.group_name][cf] = value
return dict(grouped_custom_fields)

View File

@@ -7,6 +7,7 @@ from django.db.models.fields.related import RelatedField
from django_tables2.data import TableQuerysetData
from extras.models import CustomField, CustomLink
from extras.choices import CustomFieldVisibilityChoices
from netbox.tables import columns
from utilities.paginator import EnhancedPaginator, get_paginate_count
@@ -178,7 +179,10 @@ class NetBoxTable(BaseTable):
# Add custom field & custom link columns
content_type = ContentType.objects.get_for_model(self._meta.model)
custom_fields = CustomField.objects.filter(content_types=content_type)
custom_fields = CustomField.objects.filter(
content_types=content_type
).exclude(ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_HIDDEN)
extra_columns.extend([
(f'cf_{cf.name}', columns.CustomFieldColumn(cf)) for cf in custom_fields
])

View File

@@ -42,6 +42,14 @@
<th scope="row">Weight</th>
<td>{{ object.weight }}</td>
</tr>
<tr>
<th scope="row">Filter Logic</th>
<td>{{ object.get_filter_logic_display }}</td>
</tr>
<tr>
<th scope="row">UI Visibility</th>
<td>{{ object.get_ui_visibility_display }}</td>
</tr>
</table>
</div>
</div>
@@ -65,10 +73,6 @@
{% endif %}
</td>
</tr>
<tr>
<th scope="row">Filter Logic</th>
<td>{{ object.get_filter_logic_display }}</td>
</tr>
</table>
</div>
</div>

View File

@@ -78,31 +78,39 @@
</div>
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">
Cluster
</h5>
<h5 class="card-header">Cluster</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">Site</th>
<td>
{{ object.site|linkify|placeholder }}
</td>
</tr>
<tr>
<th scope="row">Cluster</th>
<td>
{% if object.cluster.group %}
{{ object.cluster.group|linkify }} /
{% endif %}
{{ object.cluster|linkify }}
{{ object.cluster|linkify|placeholder }}
</td>
</tr>
<tr>
<th scope="row">Cluster Type</th>
<td>{{ object.cluster.type }}</td>
</tr>
<tr>
<th scope="row">Device</th>
<td>
{{ object.device|linkify|placeholder }}
</td>
</tr>
</table>
</div>
</div>
<div class="card">
<h5 class="card-header">
Resources
</h5>
<h5 class="card-header">Resources</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>

View File

@@ -34,15 +34,16 @@ def post_data(data):
return ret
def create_test_device(name):
def create_test_device(name, site=None, **attrs):
"""
Convenience method for creating a Device (e.g. for component testing).
"""
site, _ = Site.objects.get_or_create(name='Site 1', slug='site-1')
if site is None:
site, _ = Site.objects.get_or_create(name='Site 1', slug='site-1')
manufacturer, _ = Manufacturer.objects.get_or_create(name='Manufacturer 1', slug='manufacturer-1')
devicetype, _ = DeviceType.objects.get_or_create(model='Device Type 1', manufacturer=manufacturer)
devicerole, _ = DeviceRole.objects.get_or_create(name='Device Role 1', slug='device-role-1')
device = Device.objects.create(name=name, site=site, device_type=devicetype, device_role=devicerole)
device = Device.objects.create(name=name, site=site, device_type=devicetype, device_role=devicerole, **attrs)
return device

View File

@@ -1,7 +1,9 @@
from drf_yasg.utils import swagger_serializer_method
from rest_framework import serializers
from dcim.api.nested_serializers import NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedSiteSerializer
from dcim.api.nested_serializers import (
NestedDeviceSerializer, NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedSiteSerializer,
)
from dcim.choices import InterfaceModeChoices
from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer, NestedVRFSerializer
from ipam.models import VLAN
@@ -45,6 +47,7 @@ class ClusterSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:cluster-detail')
type = NestedClusterTypeSerializer()
group = NestedClusterGroupSerializer(required=False, allow_null=True, default=None)
status = ChoiceField(choices=ClusterStatusChoices, required=False)
tenant = NestedTenantSerializer(required=False, allow_null=True)
site = NestedSiteSerializer(required=False, allow_null=True, default=None)
device_count = serializers.IntegerField(read_only=True)
@@ -53,8 +56,8 @@ class ClusterSerializer(NetBoxModelSerializer):
class Meta:
model = Cluster
fields = [
'id', 'url', 'display', 'name', 'type', 'group', 'tenant', 'site', 'comments', 'tags', 'custom_fields',
'created', 'last_updated', 'device_count', 'virtualmachine_count',
'id', 'url', 'display', 'name', 'type', 'group', 'status', 'tenant', 'site', 'comments', 'tags',
'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count',
]
@@ -65,8 +68,9 @@ class ClusterSerializer(NetBoxModelSerializer):
class VirtualMachineSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:virtualmachine-detail')
status = ChoiceField(choices=VirtualMachineStatusChoices, required=False)
site = NestedSiteSerializer(read_only=True)
cluster = NestedClusterSerializer()
site = NestedSiteSerializer(required=False, allow_null=True)
cluster = NestedClusterSerializer(required=False, allow_null=True)
device = NestedDeviceSerializer(required=False, allow_null=True)
role = NestedDeviceRoleSerializer(required=False, allow_null=True)
tenant = NestedTenantSerializer(required=False, allow_null=True)
platform = NestedPlatformSerializer(required=False, allow_null=True)
@@ -77,9 +81,9 @@ class VirtualMachineSerializer(NetBoxModelSerializer):
class Meta:
model = VirtualMachine
fields = [
'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'role', 'tenant', 'platform', 'primary_ip',
'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'local_context_data', 'tags',
'custom_fields', 'created', 'last_updated',
'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'platform',
'primary_ip', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'local_context_data',
'tags', 'custom_fields', 'created', 'last_updated',
]
validators = []
@@ -89,9 +93,9 @@ class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer):
class Meta(VirtualMachineSerializer.Meta):
fields = [
'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'role', 'tenant', 'platform', 'primary_ip',
'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'local_context_data', 'tags',
'custom_fields', 'config_context', 'created', 'last_updated',
'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'platform',
'primary_ip', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'local_context_data',
'tags', 'custom_fields', 'config_context', 'created', 'last_updated',
]
@swagger_serializer_method(serializer_or_field=serializers.DictField)

View File

@@ -54,7 +54,7 @@ class ClusterViewSet(NetBoxModelViewSet):
class VirtualMachineViewSet(ConfigContextQuerySetMixin, NetBoxModelViewSet):
queryset = VirtualMachine.objects.prefetch_related(
'cluster__site', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'tags'
'site', 'cluster', 'device', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'tags'
)
filterset_class = filtersets.VirtualMachineFilterSet

View File

@@ -1,6 +1,28 @@
from utilities.choices import ChoiceSet
#
# Clusters
#
class ClusterStatusChoices(ChoiceSet):
key = 'Cluster.status'
STATUS_PLANNED = 'planned'
STATUS_STAGING = 'staging'
STATUS_ACTIVE = 'active'
STATUS_DECOMMISSIONING = 'decommissioning'
STATUS_OFFLINE = 'offline'
CHOICES = [
(STATUS_PLANNED, 'Planned', 'cyan'),
(STATUS_STAGING, 'Staging', 'blue'),
(STATUS_ACTIVE, 'Active', 'green'),
(STATUS_DECOMMISSIONING, 'Decommissioning', 'yellow'),
(STATUS_OFFLINE, 'Offline', 'red'),
]
#
# VirtualMachines
#

View File

@@ -1,7 +1,7 @@
import django_filters
from django.db.models import Q
from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup
from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup
from extras.filtersets import LocalConfigContextFilterSet
from ipam.models import VRF
from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet
@@ -90,6 +90,10 @@ class ClusterFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilte
to_field_name='slug',
label='Cluster type (slug)',
)
status = django_filters.MultipleChoiceFilter(
choices=ClusterStatusChoices,
null_value=None
)
class Meta:
model = Cluster
@@ -146,39 +150,48 @@ class VirtualMachineFilterSet(
to_field_name='name',
label='Cluster',
)
device_id = django_filters.ModelMultipleChoiceFilter(
queryset=Device.objects.all(),
label='Device (ID)',
)
device = django_filters.ModelMultipleChoiceFilter(
field_name='device__name',
queryset=Device.objects.all(),
to_field_name='name',
label='Device',
)
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='cluster__site__region',
field_name='site__region',
lookup_expr='in',
label='Region (ID)',
)
region = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='cluster__site__region',
field_name='site__region',
lookup_expr='in',
to_field_name='slug',
label='Region (slug)',
)
site_group_id = TreeNodeMultipleChoiceFilter(
queryset=SiteGroup.objects.all(),
field_name='cluster__site__group',
field_name='site__group',
lookup_expr='in',
label='Site group (ID)',
)
site_group = TreeNodeMultipleChoiceFilter(
queryset=SiteGroup.objects.all(),
field_name='cluster__site__group',
field_name='site__group',
lookup_expr='in',
to_field_name='slug',
label='Site group (slug)',
)
site_id = django_filters.ModelMultipleChoiceFilter(
field_name='cluster__site',
queryset=Site.objects.all(),
label='Site (ID)',
)
site = django_filters.ModelMultipleChoiceFilter(
field_name='cluster__site__slug',
field_name='site__slug',
queryset=Site.objects.all(),
to_field_name='slug',
label='Site (slug)',

View File

@@ -2,7 +2,7 @@ from django import forms
from dcim.choices import InterfaceModeChoices
from dcim.constants import INTERFACE_MTU_MAX, INTERFACE_MTU_MIN
from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup
from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup
from ipam.models import VLAN, VRF
from netbox.forms import NetBoxModelBulkEditForm
from tenancy.models import Tenant
@@ -58,6 +58,12 @@ class ClusterBulkEditForm(NetBoxModelBulkEditForm):
queryset=ClusterGroup.objects.all(),
required=False
)
status = forms.ChoiceField(
choices=add_blank_choice(ClusterStatusChoices),
required=False,
initial='',
widget=StaticSelect()
)
tenant = DynamicModelChoiceField(
queryset=Tenant.objects.all(),
required=False
@@ -85,7 +91,7 @@ class ClusterBulkEditForm(NetBoxModelBulkEditForm):
model = Cluster
fieldsets = (
(None, ('type', 'group', 'tenant',)),
(None, ('type', 'group', 'status', 'tenant',)),
('Site', ('region', 'site_group', 'site',)),
)
nullable_fields = (
@@ -100,9 +106,23 @@ class VirtualMachineBulkEditForm(NetBoxModelBulkEditForm):
initial='',
widget=StaticSelect(),
)
site = DynamicModelChoiceField(
queryset=Site.objects.all(),
required=False
)
cluster = DynamicModelChoiceField(
queryset=Cluster.objects.all(),
required=False
required=False,
query_params={
'site_id': '$site'
}
)
device = DynamicModelChoiceField(
queryset=Device.objects.all(),
required=False,
query_params={
'cluster_id': '$cluster'
}
)
role = DynamicModelChoiceField(
queryset=DeviceRole.objects.filter(
@@ -140,11 +160,11 @@ class VirtualMachineBulkEditForm(NetBoxModelBulkEditForm):
model = VirtualMachine
fieldsets = (
(None, ('cluster', 'status', 'role', 'tenant', 'platform')),
(None, ('site', 'cluster', 'device', 'status', 'role', 'tenant', 'platform')),
('Resources', ('vcpus', 'memory', 'disk'))
)
nullable_fields = (
'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments',
'site', 'cluster', 'device', 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments',
)
@@ -223,8 +243,10 @@ class VMInterfaceBulkEditForm(NetBoxModelBulkEditForm):
# See 5643
if 'pk' in self.initial:
site = None
interfaces = VMInterface.objects.filter(pk__in=self.initial['pk']).prefetch_related(
'virtual_machine__cluster__site'
interfaces = VMInterface.objects.filter(
pk__in=self.initial['pk']
).prefetch_related(
'virtual_machine__site'
)
# Check interface sites. First interface should set site, further interfaces will either continue the

View File

@@ -1,5 +1,5 @@
from dcim.choices import InterfaceModeChoices
from dcim.models import DeviceRole, Platform, Site
from dcim.models import Device, DeviceRole, Platform, Site
from ipam.models import VRF
from netbox.forms import NetBoxModelCSVForm
from tenancy.models import Tenant
@@ -44,6 +44,10 @@ class ClusterCSVForm(NetBoxModelCSVForm):
required=False,
help_text='Assigned cluster group'
)
status = CSVChoiceField(
choices=ClusterStatusChoices,
help_text='Operational status'
)
site = CSVModelChoiceField(
queryset=Site.objects.all(),
to_field_name='name',
@@ -59,7 +63,7 @@ class ClusterCSVForm(NetBoxModelCSVForm):
class Meta:
model = Cluster
fields = ('name', 'type', 'group', 'site', 'comments')
fields = ('name', 'type', 'group', 'status', 'site', 'comments')
class VirtualMachineCSVForm(NetBoxModelCSVForm):
@@ -67,11 +71,24 @@ class VirtualMachineCSVForm(NetBoxModelCSVForm):
choices=VirtualMachineStatusChoices,
help_text='Operational status'
)
site = CSVModelChoiceField(
queryset=Site.objects.all(),
to_field_name='name',
required=False,
help_text='Assigned site'
)
cluster = CSVModelChoiceField(
queryset=Cluster.objects.all(),
to_field_name='name',
required=False,
help_text='Assigned cluster'
)
device = CSVModelChoiceField(
queryset=Device.objects.all(),
to_field_name='name',
required=False,
help_text='Assigned device within cluster'
)
role = CSVModelChoiceField(
queryset=DeviceRole.objects.filter(
vm_role=True
@@ -96,7 +113,8 @@ class VirtualMachineCSVForm(NetBoxModelCSVForm):
class Meta:
model = VirtualMachine
fields = (
'name', 'status', 'role', 'cluster', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments',
'name', 'status', 'role', 'site', 'cluster', 'device', 'tenant', 'platform', 'vcpus', 'memory', 'disk',
'comments',
)

View File

@@ -1,7 +1,7 @@
from django import forms
from django.utils.translation import gettext as _
from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup
from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup
from extras.forms import LocalConfigContextFilterForm
from ipam.models import VRF
from netbox.forms import NetBoxModelFilterSetForm
@@ -35,7 +35,7 @@ class ClusterFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi
model = Cluster
fieldsets = (
(None, ('q', 'tag')),
('Attributes', ('group_id', 'type_id')),
('Attributes', ('group_id', 'type_id', 'status')),
('Location', ('region_id', 'site_group_id', 'site_id')),
('Tenant', ('tenant_group_id', 'tenant_id')),
('Contacts', ('contact', 'contact_role')),
@@ -50,6 +50,10 @@ class ClusterFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi
required=False,
label=_('Region')
)
status = MultipleChoiceField(
choices=ClusterStatusChoices,
required=False
)
site_group_id = DynamicModelMultipleChoiceField(
queryset=SiteGroup.objects.all(),
required=False,
@@ -83,7 +87,7 @@ class VirtualMachineFilterForm(
model = VirtualMachine
fieldsets = (
(None, ('q', 'tag')),
('Cluster', ('cluster_group_id', 'cluster_type_id', 'cluster_id')),
('Cluster', ('cluster_group_id', 'cluster_type_id', 'cluster_id', 'device_id')),
('Location', ('region_id', 'site_group_id', 'site_id')),
('Attriubtes', ('status', 'role_id', 'platform_id', 'mac_address', 'has_primary_ip', 'local_context_data')),
('Tenant', ('tenant_group_id', 'tenant_id')),
@@ -106,6 +110,11 @@ class VirtualMachineFilterForm(
required=False,
label=_('Cluster')
)
device_id = DynamicModelMultipleChoiceField(
queryset=Device.objects.all(),
required=False,
label=_('Device')
)
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,

View File

@@ -79,15 +79,19 @@ class ClusterForm(TenancyForm, NetBoxModelForm):
comments = CommentField()
fieldsets = (
('Cluster', ('name', 'type', 'group', 'region', 'site_group', 'site', 'tags')),
('Cluster', ('name', 'type', 'group', 'status', 'tags')),
('Site', ('region', 'site_group', 'site')),
('Tenancy', ('tenant_group', 'tenant')),
)
class Meta:
model = Cluster
fields = (
'name', 'type', 'group', 'tenant', 'region', 'site_group', 'site', 'comments', 'tags',
'name', 'type', 'group', 'status', 'tenant', 'region', 'site_group', 'site', 'comments', 'tags',
)
widgets = {
'status': StaticSelect(),
}
class ClusterAddDevicesForm(BootstrapMixin, forms.Form):
@@ -161,6 +165,9 @@ class ClusterRemoveDevicesForm(ConfirmationForm):
class VirtualMachineForm(TenancyForm, NetBoxModelForm):
site = DynamicModelChoiceField(
queryset=Site.objects.all()
)
cluster_group = DynamicModelChoiceField(
queryset=ClusterGroup.objects.all(),
required=False,
@@ -172,7 +179,15 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm):
cluster = DynamicModelChoiceField(
queryset=Cluster.objects.all(),
query_params={
'group_id': '$cluster_group'
'site_id': '$site',
'group_id': '$cluster_group',
}
)
device = DynamicModelChoiceField(
queryset=Device.objects.all(),
required=False,
query_params={
'cluster_id': '$cluster'
}
)
role = DynamicModelChoiceField(
@@ -193,7 +208,7 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm):
fieldsets = (
('Virtual Machine', ('name', 'role', 'status', 'tags')),
('Cluster', ('cluster_group', 'cluster')),
('Cluster', ('site', 'cluster_group', 'cluster', 'device')),
('Tenancy', ('tenant_group', 'tenant')),
('Management', ('platform', 'primary_ip4', 'primary_ip6')),
('Resources', ('vcpus', 'memory', 'disk')),
@@ -203,8 +218,9 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm):
class Meta:
model = VirtualMachine
fields = [
'name', 'status', 'cluster_group', 'cluster', 'role', 'tenant_group', 'tenant', 'platform', 'primary_ip4',
'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'tags', 'local_context_data',
'name', 'status', 'site', 'cluster_group', 'cluster', 'device', 'role', 'tenant_group', 'tenant',
'platform', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'tags',
'local_context_data',
]
help_texts = {
'local_context_data': "Local config context data overwrites all sources contexts in the final rendered "

View File

@@ -0,0 +1,18 @@
# Generated by Django 4.0.4 on 2022-05-19 19:36
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('virtualization', '0029_created_datetimefield'),
]
operations = [
migrations.AddField(
model_name='cluster',
name='status',
field=models.CharField(default='active', max_length=50),
),
]

View File

@@ -0,0 +1,28 @@
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('dcim', '0153_created_datetimefield'),
('virtualization', '0030_cluster_status'),
]
operations = [
migrations.AddField(
model_name='virtualmachine',
name='site',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='virtual_machines', to='dcim.site'),
),
migrations.AddField(
model_name='virtualmachine',
name='device',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='virtual_machines', to='dcim.device'),
),
migrations.AlterField(
model_name='virtualmachine',
name='cluster',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='virtual_machines', to='virtualization.cluster'),
),
]

View File

@@ -0,0 +1,27 @@
from django.db import migrations
def update_virtualmachines_site(apps, schema_editor):
"""
Automatically set the site for all virtual machines.
"""
VirtualMachine = apps.get_model('virtualization', 'VirtualMachine')
virtual_machines = VirtualMachine.objects.filter(cluster__site__isnull=False)
for vm in virtual_machines:
vm.site = vm.cluster.site
VirtualMachine.objects.bulk_update(virtual_machines, ['site'])
class Migration(migrations.Migration):
dependencies = [
('virtualization', '0031_virtualmachine_site_device'),
]
operations = [
migrations.RunPython(
code=update_virtualmachines_site,
reverse_code=migrations.RunPython.noop
),
]

View File

@@ -119,6 +119,11 @@ class Cluster(NetBoxModel):
blank=True,
null=True
)
status = models.CharField(
max_length=50,
choices=ClusterStatusChoices,
default=ClusterStatusChoices.STATUS_ACTIVE
)
tenant = models.ForeignKey(
to='tenancy.Tenant',
on_delete=models.PROTECT,
@@ -165,6 +170,9 @@ class Cluster(NetBoxModel):
def get_absolute_url(self):
return reverse('virtualization:cluster', args=[self.pk])
def get_status_color(self):
return ClusterStatusChoices.colors.get(self.status)
def clean(self):
super().clean()
@@ -187,10 +195,26 @@ class VirtualMachine(NetBoxModel, ConfigContextModel):
"""
A virtual machine which runs inside a Cluster.
"""
site = models.ForeignKey(
to='dcim.Site',
on_delete=models.PROTECT,
related_name='virtual_machines',
blank=True,
null=True
)
cluster = models.ForeignKey(
to='virtualization.Cluster',
on_delete=models.PROTECT,
related_name='virtual_machines'
related_name='virtual_machines',
blank=True,
null=True
)
device = models.ForeignKey(
to='dcim.Device',
on_delete=models.PROTECT,
related_name='virtual_machines',
blank=True,
null=True
)
tenant = models.ForeignKey(
to='tenancy.Tenant',
@@ -276,7 +300,7 @@ class VirtualMachine(NetBoxModel, ConfigContextModel):
objects = ConfigContextModelQuerySet.as_manager()
clone_fields = [
'cluster', 'tenant', 'platform', 'status', 'role', 'vcpus', 'memory', 'disk',
'site', 'cluster', 'device', 'tenant', 'platform', 'status', 'role', 'vcpus', 'memory', 'disk',
]
class Meta:
@@ -308,6 +332,28 @@ class VirtualMachine(NetBoxModel, ConfigContextModel):
def clean(self):
super().clean()
# Must be assigned to a site and/or cluster
if not self.site and not self.cluster:
raise ValidationError({
'cluster': f'A virtual machine must be assigned to a site and/or cluster.'
})
# Validate site for cluster & device
if self.cluster and self.cluster.site != self.site:
raise ValidationError({
'cluster': f'The selected cluster ({self.cluster} is not assigned to this site ({self.site}).'
})
if self.device and self.device.site != self.site:
raise ValidationError({
'device': f'The selected device ({self.device} is not assigned to this site ({self.site}).'
})
# Validate assigned cluster device
if self.device and self.device not in self.cluster.devices.all():
raise ValidationError({
'device': f'The selected device ({self.device} is not assigned to this cluster ({self.cluster}).'
})
# Validate primary IP addresses
interfaces = self.interfaces.all()
for field in ['primary_ip4', 'primary_ip6']:
@@ -336,10 +382,6 @@ class VirtualMachine(NetBoxModel, ConfigContextModel):
else:
return None
@property
def site(self):
return self.cluster.site
#
# Interfaces

View File

@@ -66,6 +66,7 @@ class ClusterTable(NetBoxTable):
group = tables.Column(
linkify=True
)
status = columns.ChoiceFieldColumn()
tenant = tables.Column(
linkify=True
)
@@ -93,7 +94,7 @@ class ClusterTable(NetBoxTable):
class Meta(NetBoxTable.Meta):
model = Cluster
fields = (
'pk', 'id', 'name', 'type', 'group', 'tenant', 'site', 'comments', 'device_count', 'vm_count', 'contacts',
'tags', 'created', 'last_updated',
'pk', 'id', 'name', 'type', 'group', 'status', 'tenant', 'site', 'comments', 'device_count', 'vm_count',
'contacts', 'tags', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'type', 'group', 'tenant', 'site', 'device_count', 'vm_count')
default_columns = ('pk', 'name', 'type', 'group', 'status', 'tenant', 'site', 'device_count', 'vm_count')

View File

@@ -30,9 +30,15 @@ class VirtualMachineTable(NetBoxTable):
linkify=True
)
status = columns.ChoiceFieldColumn()
site = tables.Column(
linkify=True
)
cluster = tables.Column(
linkify=True
)
device = tables.Column(
linkify=True
)
role = columns.ColoredLabelColumn()
tenant = TenantColumn()
comments = columns.MarkdownColumn()
@@ -56,11 +62,11 @@ class VirtualMachineTable(NetBoxTable):
class Meta(NetBoxTable.Meta):
model = VirtualMachine
fields = (
'pk', 'id', 'name', 'status', 'cluster', 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk',
'primary_ip4', 'primary_ip6', 'primary_ip', 'comments', 'tags', 'created', 'last_updated',
'pk', 'id', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'platform', 'vcpus', 'memory',
'disk', 'primary_ip4', 'primary_ip6', 'primary_ip', 'comments', 'tags', 'created', 'last_updated',
)
default_columns = (
'pk', 'name', 'status', 'cluster', 'role', 'tenant', 'vcpus', 'memory', 'disk', 'primary_ip',
'pk', 'name', 'status', 'site', 'cluster', 'role', 'tenant', 'vcpus', 'memory', 'disk', 'primary_ip',
)

View File

@@ -2,8 +2,10 @@ from django.urls import reverse
from rest_framework import status
from dcim.choices import InterfaceModeChoices
from dcim.models import Site
from ipam.models import VLAN, VRF
from utilities.testing import APITestCase, APIViewTestCases
from utilities.testing import APITestCase, APIViewTestCases, create_test_device
from virtualization.choices import *
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
@@ -85,6 +87,7 @@ class ClusterTest(APIViewTestCases.APIViewTestCase):
model = Cluster
brief_fields = ['display', 'id', 'name', 'url', 'virtualmachine_count']
bulk_update_data = {
'status': 'offline',
'comments': 'New comment',
}
@@ -104,9 +107,9 @@ class ClusterTest(APIViewTestCases.APIViewTestCase):
ClusterGroup.objects.bulk_create(cluster_groups)
clusters = (
Cluster(name='Cluster 1', type=cluster_types[0], group=cluster_groups[0]),
Cluster(name='Cluster 2', type=cluster_types[0], group=cluster_groups[0]),
Cluster(name='Cluster 3', type=cluster_types[0], group=cluster_groups[0]),
Cluster(name='Cluster 1', type=cluster_types[0], group=cluster_groups[0], status=ClusterStatusChoices.STATUS_PLANNED),
Cluster(name='Cluster 2', type=cluster_types[0], group=cluster_groups[0], status=ClusterStatusChoices.STATUS_PLANNED),
Cluster(name='Cluster 3', type=cluster_types[0], group=cluster_groups[0], status=ClusterStatusChoices.STATUS_PLANNED),
)
Cluster.objects.bulk_create(clusters)
@@ -115,16 +118,19 @@ class ClusterTest(APIViewTestCases.APIViewTestCase):
'name': 'Cluster 4',
'type': cluster_types[1].pk,
'group': cluster_groups[1].pk,
'status': ClusterStatusChoices.STATUS_STAGING,
},
{
'name': 'Cluster 5',
'type': cluster_types[1].pk,
'group': cluster_groups[1].pk,
'status': ClusterStatusChoices.STATUS_STAGING,
},
{
'name': 'Cluster 6',
'type': cluster_types[1].pk,
'group': cluster_groups[1].pk,
'status': ClusterStatusChoices.STATUS_STAGING,
},
]
@@ -141,31 +147,49 @@ class VirtualMachineTest(APIViewTestCases.APIViewTestCase):
clustertype = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
clustergroup = ClusterGroup.objects.create(name='Cluster Group 1', slug='cluster-group-1')
sites = (
Site(name='Site 1', slug='site-1'),
Site(name='Site 2', slug='site-2'),
Site(name='Site 3', slug='site-3'),
)
Site.objects.bulk_create(sites)
clusters = (
Cluster(name='Cluster 1', type=clustertype, group=clustergroup),
Cluster(name='Cluster 2', type=clustertype, group=clustergroup),
Cluster(name='Cluster 1', type=clustertype, site=sites[0], group=clustergroup),
Cluster(name='Cluster 2', type=clustertype, site=sites[1], group=clustergroup),
Cluster(name='Cluster 3', type=clustertype),
)
Cluster.objects.bulk_create(clusters)
device1 = create_test_device('device1', site=sites[0], cluster=clusters[0])
device2 = create_test_device('device2', site=sites[1], cluster=clusters[1])
virtual_machines = (
VirtualMachine(name='Virtual Machine 1', cluster=clusters[0], local_context_data={'A': 1}),
VirtualMachine(name='Virtual Machine 2', cluster=clusters[0], local_context_data={'B': 2}),
VirtualMachine(name='Virtual Machine 3', cluster=clusters[0], local_context_data={'C': 3}),
VirtualMachine(name='Virtual Machine 1', site=sites[0], cluster=clusters[0], device=device1, local_context_data={'A': 1}),
VirtualMachine(name='Virtual Machine 2', site=sites[0], cluster=clusters[0], local_context_data={'B': 2}),
VirtualMachine(name='Virtual Machine 3', site=sites[0], cluster=clusters[0], local_context_data={'C': 3}),
)
VirtualMachine.objects.bulk_create(virtual_machines)
cls.create_data = [
{
'name': 'Virtual Machine 4',
'site': sites[1].pk,
'cluster': clusters[1].pk,
'device': device2.pk,
},
{
'name': 'Virtual Machine 5',
'site': sites[1].pk,
'cluster': clusters[1].pk,
},
{
'name': 'Virtual Machine 6',
'cluster': clusters[1].pk,
'site': sites[1].pk,
},
{
'name': 'Virtual Machine 7',
'cluster': clusters[2].pk,
},
]

View File

@@ -1,9 +1,9 @@
from django.test import TestCase
from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup
from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup
from ipam.models import IPAddress, VRF
from tenancy.models import Tenant, TenantGroup
from utilities.testing import ChangeLoggedFilterSetTests
from utilities.testing import ChangeLoggedFilterSetTests, create_test_device
from virtualization.choices import *
from virtualization.filtersets import *
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
@@ -123,9 +123,9 @@ class ClusterTestCase(TestCase, ChangeLoggedFilterSetTests):
Tenant.objects.bulk_create(tenants)
clusters = (
Cluster(name='Cluster 1', type=cluster_types[0], group=cluster_groups[0], site=sites[0], tenant=tenants[0]),
Cluster(name='Cluster 2', type=cluster_types[1], group=cluster_groups[1], site=sites[1], tenant=tenants[1]),
Cluster(name='Cluster 3', type=cluster_types[2], group=cluster_groups[2], site=sites[2], tenant=tenants[2]),
Cluster(name='Cluster 1', type=cluster_types[0], group=cluster_groups[0], status=ClusterStatusChoices.STATUS_PLANNED, site=sites[0], tenant=tenants[0]),
Cluster(name='Cluster 2', type=cluster_types[1], group=cluster_groups[1], status=ClusterStatusChoices.STATUS_STAGING, site=sites[1], tenant=tenants[1]),
Cluster(name='Cluster 3', type=cluster_types[2], group=cluster_groups[2], status=ClusterStatusChoices.STATUS_ACTIVE, site=sites[2], tenant=tenants[2]),
)
Cluster.objects.bulk_create(clusters)
@@ -161,6 +161,10 @@ class ClusterTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'group': [groups[0].slug, groups[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_status(self):
params = {'status': [ClusterStatusChoices.STATUS_PLANNED, ClusterStatusChoices.STATUS_STAGING]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_type(self):
types = ClusterType.objects.all()[:2]
params = {'type_id': [types[0].pk, types[1].pk]}
@@ -221,9 +225,9 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests):
site_group.save()
sites = (
Site(name='Test Site 1', slug='test-site-1', region=regions[0], group=site_groups[0]),
Site(name='Test Site 2', slug='test-site-2', region=regions[1], group=site_groups[1]),
Site(name='Test Site 3', slug='test-site-3', region=regions[2], group=site_groups[2]),
Site(name='Site 1', slug='site-1', region=regions[0], group=site_groups[0]),
Site(name='Site 2', slug='site-2', region=regions[1], group=site_groups[1]),
Site(name='Site 3', slug='site-3', region=regions[2], group=site_groups[2]),
)
Site.objects.bulk_create(sites)
@@ -248,6 +252,12 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests):
)
DeviceRole.objects.bulk_create(roles)
devices = (
create_test_device('device1', cluster=clusters[0]),
create_test_device('device2', cluster=clusters[1]),
create_test_device('device3', cluster=clusters[2]),
)
tenant_groups = (
TenantGroup(name='Tenant group 1', slug='tenant-group-1'),
TenantGroup(name='Tenant group 2', slug='tenant-group-2'),
@@ -264,9 +274,9 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests):
Tenant.objects.bulk_create(tenants)
vms = (
VirtualMachine(name='Virtual Machine 1', cluster=clusters[0], platform=platforms[0], role=roles[0], tenant=tenants[0], status=VirtualMachineStatusChoices.STATUS_ACTIVE, vcpus=1, memory=1, disk=1, local_context_data={"foo": 123}),
VirtualMachine(name='Virtual Machine 2', cluster=clusters[1], platform=platforms[1], role=roles[1], tenant=tenants[1], status=VirtualMachineStatusChoices.STATUS_STAGED, vcpus=2, memory=2, disk=2),
VirtualMachine(name='Virtual Machine 3', cluster=clusters[2], platform=platforms[2], role=roles[2], tenant=tenants[2], status=VirtualMachineStatusChoices.STATUS_OFFLINE, vcpus=3, memory=3, disk=3),
VirtualMachine(name='Virtual Machine 1', site=sites[0], cluster=clusters[0], device=devices[0], platform=platforms[0], role=roles[0], tenant=tenants[0], status=VirtualMachineStatusChoices.STATUS_ACTIVE, vcpus=1, memory=1, disk=1, local_context_data={"foo": 123}),
VirtualMachine(name='Virtual Machine 2', site=sites[1], cluster=clusters[1], device=devices[1], platform=platforms[1], role=roles[1], tenant=tenants[1], status=VirtualMachineStatusChoices.STATUS_STAGED, vcpus=2, memory=2, disk=2),
VirtualMachine(name='Virtual Machine 3', site=sites[2], cluster=clusters[2], device=devices[2], platform=platforms[2], role=roles[2], tenant=tenants[2], status=VirtualMachineStatusChoices.STATUS_OFFLINE, vcpus=3, memory=3, disk=3),
)
VirtualMachine.objects.bulk_create(vms)
@@ -327,6 +337,13 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'cluster': [clusters[0].name, clusters[1].name]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_device(self):
devices = Device.objects.all()[:2]
params = {'device_id': [devices[0].pk, devices[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'device': [devices[0].name, devices[1].name]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_region(self):
regions = Region.objects.all()[:2]
params = {'region_id': [regions[0].pk, regions[1].pk]}

View File

@@ -1,21 +1,19 @@
from django.core.exceptions import ValidationError
from django.test import TestCase
from dcim.models import Site
from virtualization.models import *
from tenancy.models import Tenant
class VirtualMachineTestCase(TestCase):
def setUp(self):
cluster_type = ClusterType.objects.create(name='Test Cluster Type 1', slug='Test Cluster Type 1')
self.cluster = Cluster.objects.create(name='Test Cluster 1', type=cluster_type)
def test_vm_duplicate_name_per_cluster(self):
cluster_type = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
cluster = Cluster.objects.create(name='Cluster 1', type=cluster_type)
vm1 = VirtualMachine(
cluster=self.cluster,
cluster=cluster,
name='Test VM 1'
)
vm1.save()
@@ -43,3 +41,33 @@ class VirtualMachineTestCase(TestCase):
# Two VMs assigned to the same Cluster and different Tenants should pass validation
vm2.full_clean()
vm2.save()
def test_vm_mismatched_site_cluster(self):
cluster_type = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
sites = (
Site(name='Site 1', slug='site-1'),
Site(name='Site 2', slug='site-2'),
)
Site.objects.bulk_create(sites)
clusters = (
Cluster(name='Cluster 1', type=cluster_type, site=sites[0]),
Cluster(name='Cluster 2', type=cluster_type, site=sites[1]),
Cluster(name='Cluster 3', type=cluster_type, site=None),
)
Cluster.objects.bulk_create(clusters)
# VM with site only should pass
VirtualMachine(name='vm1', site=sites[0]).full_clean()
# VM with non-site cluster only should pass
VirtualMachine(name='vm1', cluster=clusters[2]).full_clean()
# VM with mismatched site & cluster should fail
with self.assertRaises(ValidationError):
VirtualMachine(name='vm1', site=sites[0], cluster=clusters[1]).full_clean()
# VM with cluster site but no direct site should fail
with self.assertRaises(ValidationError):
VirtualMachine(name='vm1', site=None, cluster=clusters[0]).full_clean()

View File

@@ -5,7 +5,7 @@ from netaddr import EUI
from dcim.choices import InterfaceModeChoices
from dcim.models import DeviceRole, Platform, Site
from ipam.models import VLAN, VRF
from utilities.testing import ViewTestCases, create_tags
from utilities.testing import ViewTestCases, create_tags, create_test_device
from virtualization.choices import *
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
@@ -101,9 +101,9 @@ class ClusterTestCase(ViewTestCases.PrimaryObjectViewTestCase):
ClusterType.objects.bulk_create(clustertypes)
Cluster.objects.bulk_create([
Cluster(name='Cluster 1', group=clustergroups[0], type=clustertypes[0], site=sites[0]),
Cluster(name='Cluster 2', group=clustergroups[0], type=clustertypes[0], site=sites[0]),
Cluster(name='Cluster 3', group=clustergroups[0], type=clustertypes[0], site=sites[0]),
Cluster(name='Cluster 1', group=clustergroups[0], type=clustertypes[0], status=ClusterStatusChoices.STATUS_ACTIVE, site=sites[0]),
Cluster(name='Cluster 2', group=clustergroups[0], type=clustertypes[0], status=ClusterStatusChoices.STATUS_ACTIVE, site=sites[0]),
Cluster(name='Cluster 3', group=clustergroups[0], type=clustertypes[0], status=ClusterStatusChoices.STATUS_ACTIVE, site=sites[0]),
])
tags = create_tags('Alpha', 'Bravo', 'Charlie')
@@ -112,6 +112,7 @@ class ClusterTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'name': 'Cluster X',
'group': clustergroups[1].pk,
'type': clustertypes[1].pk,
'status': ClusterStatusChoices.STATUS_OFFLINE,
'tenant': None,
'site': sites[1].pk,
'comments': 'Some comments',
@@ -119,15 +120,16 @@ class ClusterTestCase(ViewTestCases.PrimaryObjectViewTestCase):
}
cls.csv_data = (
"name,type",
"Cluster 4,Cluster Type 1",
"Cluster 5,Cluster Type 1",
"Cluster 6,Cluster Type 1",
"name,type,status",
"Cluster 4,Cluster Type 1,active",
"Cluster 5,Cluster Type 1,active",
"Cluster 6,Cluster Type 1,active",
)
cls.bulk_edit_data = {
'group': clustergroups[1].pk,
'type': clustertypes[1].pk,
'status': ClusterStatusChoices.STATUS_OFFLINE,
'tenant': None,
'site': sites[1].pk,
'comments': 'New comments',
@@ -166,24 +168,37 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase):
)
Platform.objects.bulk_create(platforms)
sites = (
Site(name='Site 1', slug='site-1'),
Site(name='Site 2', slug='site-2'),
)
Site.objects.bulk_create(sites)
clustertype = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
clusters = (
Cluster(name='Cluster 1', type=clustertype),
Cluster(name='Cluster 2', type=clustertype),
Cluster(name='Cluster 1', type=clustertype, site=sites[0]),
Cluster(name='Cluster 2', type=clustertype, site=sites[1]),
)
Cluster.objects.bulk_create(clusters)
devices = (
create_test_device('device1', site=sites[0], cluster=clusters[0]),
create_test_device('device2', site=sites[1], cluster=clusters[1]),
)
VirtualMachine.objects.bulk_create([
VirtualMachine(name='Virtual Machine 1', cluster=clusters[0], role=deviceroles[0], platform=platforms[0]),
VirtualMachine(name='Virtual Machine 2', cluster=clusters[0], role=deviceroles[0], platform=platforms[0]),
VirtualMachine(name='Virtual Machine 3', cluster=clusters[0], role=deviceroles[0], platform=platforms[0]),
VirtualMachine(name='Virtual Machine 1', site=sites[0], cluster=clusters[0], device=devices[0], role=deviceroles[0], platform=platforms[0]),
VirtualMachine(name='Virtual Machine 2', site=sites[0], cluster=clusters[0], device=devices[0], role=deviceroles[0], platform=platforms[0]),
VirtualMachine(name='Virtual Machine 3', site=sites[0], cluster=clusters[0], device=devices[0], role=deviceroles[0], platform=platforms[0]),
])
tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = {
'cluster': clusters[1].pk,
'device': devices[1].pk,
'site': sites[1].pk,
'tenant': None,
'platform': platforms[1].pk,
'name': 'Virtual Machine X',
@@ -200,14 +215,16 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase):
}
cls.csv_data = (
"name,status,cluster",
"Virtual Machine 4,active,Cluster 1",
"Virtual Machine 5,active,Cluster 1",
"Virtual Machine 6,active,Cluster 1",
"name,status,site,cluster,device",
"Virtual Machine 4,active,Site 1,Cluster 1,device1",
"Virtual Machine 5,active,Site 1,Cluster 1,device1",
"Virtual Machine 6,active,Site 1,Cluster 1,",
)
cls.bulk_edit_data = {
'site': sites[1].pk,
'cluster': clusters[1].pk,
'device': devices[1].pk,
'tenant': None,
'platform': platforms[1].pk,
'status': VirtualMachineStatusChoices.STATUS_STAGED,
@@ -243,8 +260,8 @@ class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
clustertype = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
cluster = Cluster.objects.create(name='Cluster 1', type=clustertype, site=site)
virtualmachines = (
VirtualMachine(name='Virtual Machine 1', cluster=cluster, role=devicerole),
VirtualMachine(name='Virtual Machine 2', cluster=cluster, role=devicerole),
VirtualMachine(name='Virtual Machine 1', site=site, cluster=cluster, role=devicerole),
VirtualMachine(name='Virtual Machine 2', site=site, cluster=cluster, role=devicerole),
)
VirtualMachine.objects.bulk_create(virtualmachines)