closes #4368 - extras features model registration

This commit is contained in:
John Anderson 2020-03-14 03:03:22 -04:00
parent c5776d9da4
commit 9466802a95
15 changed files with 172 additions and 185 deletions

View File

@ -7,6 +7,7 @@ from dcim.constants import CONNECTION_STATUS_CHOICES
from dcim.fields import ASNField
from dcim.models import CableTermination
from extras.models import CustomFieldModel, ObjectChange, TaggedItem
from extras.utils import extras_features
from utilities.models import ChangeLoggedModel
from utilities.utils import serialize_object
from .choices import *
@ -21,6 +22,7 @@ __all__ = (
)
@extras_features('custom_fields', 'custom_links', 'graphs', 'export_templates', 'webhooks')
class Provider(ChangeLoggedModel, CustomFieldModel):
"""
Each Circuit belongs to a Provider. This is usually a telecommunications company or similar organization. This model
@ -131,6 +133,7 @@ class CircuitType(ChangeLoggedModel):
)
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
class Circuit(ChangeLoggedModel, CustomFieldModel):
"""
A communications circuit connects two points. Each Circuit belongs to a Provider; Providers may have multiple

View File

@ -21,6 +21,7 @@ from dcim.constants import *
from dcim.fields import ASNField
from dcim.elevations import RackElevationSVG
from extras.models import ConfigContextModel, CustomFieldModel, ObjectChange, TaggedItem
from extras.utils import extras_features
from utilities.fields import ColorField, NaturalOrderingField
from utilities.models import ChangeLoggedModel
from utilities.utils import serialize_object, to_meters
@ -75,6 +76,7 @@ __all__ = (
# Regions
#
@extras_features('export_templates', 'webhooks')
class Region(MPTTModel, ChangeLoggedModel):
"""
Sites can be grouped within geographic Regions.
@ -133,6 +135,7 @@ class Region(MPTTModel, ChangeLoggedModel):
# Sites
#
@extras_features('custom_fields', 'custom_links', 'graphs', 'export_templates', 'webhooks')
class Site(ChangeLoggedModel, CustomFieldModel):
"""
A Site represents a geographic location within a network; typically a building or campus. The optional facility
@ -283,6 +286,7 @@ class Site(ChangeLoggedModel, CustomFieldModel):
# Racks
#
@extras_features('export_templates')
class RackGroup(ChangeLoggedModel):
"""
Racks can be grouped as subsets within a Site. The scope of a group will depend on how Sites are defined. For
@ -359,6 +363,7 @@ class RackRole(ChangeLoggedModel):
)
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
class Rack(ChangeLoggedModel, CustomFieldModel):
"""
Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face.
@ -823,6 +828,7 @@ class RackReservation(ChangeLoggedModel):
# Device Types
#
@extras_features('export_templates', 'webhooks')
class Manufacturer(ChangeLoggedModel):
"""
A Manufacturer represents a company which produces hardware devices; for example, Juniper or Dell.
@ -853,6 +859,7 @@ class Manufacturer(ChangeLoggedModel):
)
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
class DeviceType(ChangeLoggedModel, CustomFieldModel):
"""
A DeviceType represents a particular make (Manufacturer) and model of device. It specifies rack height and depth, as
@ -1196,6 +1203,7 @@ class Platform(ChangeLoggedModel):
)
@extras_features('custom_fields', 'custom_links', 'graphs', 'export_templates', 'webhooks')
class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
"""
A Device represents a piece of physical hardware mounted within a Rack. Each Device is assigned a DeviceType,
@ -1631,6 +1639,7 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
# Virtual chassis
#
@extras_features('export_templates', 'webhooks')
class VirtualChassis(ChangeLoggedModel):
"""
A collection of Devices which operate with a shared control plane (e.g. a switch stack).
@ -1697,6 +1706,7 @@ class VirtualChassis(ChangeLoggedModel):
# Power
#
@extras_features('custom_links', 'export_templates', 'webhooks')
class PowerPanel(ChangeLoggedModel):
"""
A distribution point for electrical power; e.g. a data center RPP.
@ -1743,6 +1753,7 @@ class PowerPanel(ChangeLoggedModel):
))
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
class PowerFeed(ChangeLoggedModel, CableTermination, CustomFieldModel):
"""
An electrical circuit delivered from a PowerPanel.
@ -1904,6 +1915,7 @@ class PowerFeed(ChangeLoggedModel, CableTermination, CustomFieldModel):
# Cables
#
@extras_features('custom_links', 'export_templates', 'webhooks')
class Cable(ChangeLoggedModel):
"""
A physical connection between two endpoints.

View File

@ -11,6 +11,7 @@ from dcim.constants import *
from dcim.exceptions import LoopDetected
from dcim.fields import MACAddressField
from extras.models import ObjectChange, TaggedItem
from extras.utils import extras_features
from utilities.fields import NaturalOrderingField
from utilities.ordering import naturalize_interface
from utilities.utils import serialize_object
@ -169,6 +170,7 @@ class CableTermination(models.Model):
# Console ports
#
@extras_features('export_templates', 'webhooks')
class ConsolePort(CableTermination, ComponentModel):
"""
A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts.
@ -229,6 +231,7 @@ class ConsolePort(CableTermination, ComponentModel):
# Console server ports
#
@extras_features('webhooks')
class ConsoleServerPort(CableTermination, ComponentModel):
"""
A physical port within a Device (typically a designated console server) which provides access to ConsolePorts.
@ -282,6 +285,7 @@ class ConsoleServerPort(CableTermination, ComponentModel):
# Power ports
#
@extras_features('export_templates', 'webhooks')
class PowerPort(CableTermination, ComponentModel):
"""
A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets.
@ -443,6 +447,7 @@ class PowerPort(CableTermination, ComponentModel):
# Power outlets
#
@extras_features('webhooks')
class PowerOutlet(CableTermination, ComponentModel):
"""
A physical power outlet (output) within a Device which provides power to a PowerPort.
@ -519,6 +524,7 @@ class PowerOutlet(CableTermination, ComponentModel):
# Interfaces
#
@extras_features('graphs', 'export_templates', 'webhooks')
class Interface(CableTermination, ComponentModel):
"""
A network interface within a Device or VirtualMachine. A physical Interface can connect to exactly one other
@ -792,6 +798,7 @@ class Interface(CableTermination, ComponentModel):
# Pass-through ports
#
@extras_features('webhooks')
class FrontPort(CableTermination, ComponentModel):
"""
A pass-through port on the front of a Device.
@ -864,6 +871,7 @@ class FrontPort(CableTermination, ComponentModel):
)
@extras_features('webhooks')
class RearPort(CableTermination, ComponentModel):
"""
A pass-through port on the rear of a Device.
@ -915,6 +923,7 @@ class RearPort(CableTermination, ComponentModel):
# Device bays
#
@extras_features('webhooks')
class DeviceBay(ComponentModel):
"""
An empty space within a Device which can house a child device
@ -989,6 +998,7 @@ class DeviceBay(ComponentModel):
# Inventory items
#
@extras_features('export_templates', 'webhooks')
class InventoryItem(ComponentModel):
"""
An InventoryItem represents a serialized piece of hardware within a Device, such as a line card or power supply.

View File

@ -13,6 +13,7 @@ from extras.constants import *
from extras.models import (
ConfigContext, ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult, Tag,
)
from extras.utils import FeatureQuerySet
from tenancy.api.nested_serializers import NestedTenantSerializer, NestedTenantGroupSerializer
from tenancy.models import Tenant, TenantGroup
from users.api.nested_serializers import NestedUserSerializer
@ -31,7 +32,7 @@ from .nested_serializers import *
class GraphSerializer(ValidatedModelSerializer):
type = ContentTypeField(
queryset=ContentType.objects.filter(GRAPH_MODELS),
queryset=ContentType.objects.filter(FeatureQuerySet('graphs').get_queryset()),
)
class Meta:
@ -67,7 +68,7 @@ class RenderedGraphSerializer(serializers.ModelSerializer):
class ExportTemplateSerializer(ValidatedModelSerializer):
content_type = ContentTypeField(
queryset=ContentType.objects.filter(EXPORTTEMPLATE_MODELS),
queryset=ContentType.objects.filter(FeatureQuerySet('export_templates').get_queryset()),
)
template_language = ChoiceField(
choices=TemplateLanguageChoices,

View File

@ -1,129 +1,3 @@
from django.db.models import Q
# Models which support custom fields
CUSTOMFIELD_MODELS = Q(
Q(app_label='circuits', model__in=[
'circuit',
'provider',
]) |
Q(app_label='dcim', model__in=[
'device',
'devicetype',
'powerfeed',
'rack',
'site',
]) |
Q(app_label='ipam', model__in=[
'aggregate',
'ipaddress',
'prefix',
'service',
'vlan',
'vrf',
]) |
Q(app_label='secrets', model__in=[
'secret',
]) |
Q(app_label='tenancy', model__in=[
'tenant',
]) |
Q(app_label='virtualization', model__in=[
'cluster',
'virtualmachine',
])
)
# Custom links
CUSTOMLINK_MODELS = Q(
Q(app_label='circuits', model__in=[
'circuit',
'provider',
]) |
Q(app_label='dcim', model__in=[
'cable',
'device',
'devicetype',
'powerpanel',
'powerfeed',
'rack',
'site',
]) |
Q(app_label='ipam', model__in=[
'aggregate',
'ipaddress',
'prefix',
'service',
'vlan',
'vrf',
]) |
Q(app_label='secrets', model__in=[
'secret',
]) |
Q(app_label='tenancy', model__in=[
'tenant',
]) |
Q(app_label='virtualization', model__in=[
'cluster',
'virtualmachine',
])
)
# Models which can have Graphs associated with them
GRAPH_MODELS = Q(
Q(app_label='circuits', model__in=[
'provider',
]) |
Q(app_label='dcim', model__in=[
'device',
'interface',
'site',
])
)
# Models which support export templates
EXPORTTEMPLATE_MODELS = Q(
Q(app_label='circuits', model__in=[
'circuit',
'provider',
]) |
Q(app_label='dcim', model__in=[
'cable',
'consoleport',
'device',
'devicetype',
'interface',
'inventoryitem',
'manufacturer',
'powerpanel',
'powerport',
'powerfeed',
'rack',
'rackgroup',
'region',
'site',
'virtualchassis',
]) |
Q(app_label='ipam', model__in=[
'aggregate',
'ipaddress',
'prefix',
'service',
'vlan',
'vrf',
]) |
Q(app_label='secrets', model__in=[
'secret',
]) |
Q(app_label='tenancy', model__in=[
'tenant',
]) |
Q(app_label='virtualization', model__in=[
'cluster',
'virtualmachine',
])
)
# Report logging levels
LOG_DEFAULT = 0
LOG_SUCCESS = 10
@ -138,51 +12,14 @@ LOG_LEVEL_CODES = {
LOG_FAILURE: 'failure',
}
# Webhook content types
HTTP_CONTENT_TYPE_JSON = 'application/json'
# Models which support registered webhooks
WEBHOOK_MODELS = Q(
Q(app_label='circuits', model__in=[
'circuit',
'provider',
]) |
Q(app_label='dcim', model__in=[
'cable',
'consoleport',
'consoleserverport',
'device',
'devicebay',
'devicetype',
'frontport',
'interface',
'inventoryitem',
'manufacturer',
'poweroutlet',
'powerpanel',
'powerport',
'powerfeed',
'rack',
'rearport',
'region',
'site',
'virtualchassis',
]) |
Q(app_label='ipam', model__in=[
'aggregate',
'ipaddress',
'prefix',
'service',
'vlan',
'vrf',
]) |
Q(app_label='secrets', model__in=[
'secret',
]) |
Q(app_label='tenancy', model__in=[
'tenant',
]) |
Q(app_label='virtualization', model__in=[
'cluster',
'virtualmachine',
])
)
# Registerable extras features
EXTRAS_FEATURES = [
'custom_fields',
'custom_links',
'graphs',
'export_templates',
'webhooks'
]

View File

@ -0,0 +1,40 @@
# Generated by Django 2.2.11 on 2020-03-14 06:50
from django.db import migrations, models
import django.db.models.deletion
import extras.utils
class Migration(migrations.Migration):
dependencies = [
('extras', '0038_webhook_template_support'),
]
operations = [
migrations.AlterField(
model_name='customfield',
name='obj_type',
field=models.ManyToManyField(limit_choices_to=extras.utils.FeatureQuerySet('custom_fields'), related_name='custom_fields', to='contenttypes.ContentType'),
),
migrations.AlterField(
model_name='customlink',
name='content_type',
field=models.ForeignKey(limit_choices_to=extras.utils.FeatureQuerySet('custom_links'), on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType'),
),
migrations.AlterField(
model_name='exporttemplate',
name='content_type',
field=models.ForeignKey(limit_choices_to=extras.utils.FeatureQuerySet('export_templates'), on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType'),
),
migrations.AlterField(
model_name='graph',
name='type',
field=models.ForeignKey(limit_choices_to=extras.utils.FeatureQuerySet('graphs'), on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType'),
),
migrations.AlterField(
model_name='webhook',
name='obj_type',
field=models.ManyToManyField(limit_choices_to=extras.utils.FeatureQuerySet('webhooks'), related_name='webhooks', to='contenttypes.ContentType'),
),
]

View File

@ -22,6 +22,7 @@ from utilities.utils import deepmerge, render_jinja2
from .choices import *
from .constants import *
from .querysets import ConfigContextQuerySet
from .utils import FeatureQuerySet
__all__ = (
@ -58,7 +59,7 @@ class Webhook(models.Model):
to=ContentType,
related_name='webhooks',
verbose_name='Object types',
limit_choices_to=WEBHOOK_MODELS,
limit_choices_to=FeatureQuerySet('webhooks'),
help_text="The object(s) to which this Webhook applies."
)
name = models.CharField(
@ -223,7 +224,7 @@ class CustomField(models.Model):
to=ContentType,
related_name='custom_fields',
verbose_name='Object(s)',
limit_choices_to=CUSTOMFIELD_MODELS,
limit_choices_to=FeatureQuerySet('custom_fields'),
help_text='The object(s) to which this field applies.'
)
type = models.CharField(
@ -470,7 +471,7 @@ class CustomLink(models.Model):
content_type = models.ForeignKey(
to=ContentType,
on_delete=models.CASCADE,
limit_choices_to=CUSTOMLINK_MODELS
limit_choices_to=FeatureQuerySet('custom_links')
)
name = models.CharField(
max_length=100,
@ -518,7 +519,7 @@ class Graph(models.Model):
type = models.ForeignKey(
to=ContentType,
on_delete=models.CASCADE,
limit_choices_to=GRAPH_MODELS
limit_choices_to=FeatureQuerySet('graphs')
)
weight = models.PositiveSmallIntegerField(
default=1000
@ -581,7 +582,7 @@ class ExportTemplate(models.Model):
content_type = models.ForeignKey(
to=ContentType,
on_delete=models.CASCADE,
limit_choices_to=EXPORTTEMPLATE_MODELS
limit_choices_to=FeatureQuerySet('export_templates')
)
name = models.CharField(
max_length=100

View File

@ -8,9 +8,9 @@ from rest_framework import status
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Platform, Rack, RackGroup, RackRole, Region, Site
from extras.api.views import ScriptViewSet
from extras.choices import *
from extras.constants import GRAPH_MODELS
from extras.models import ConfigContext, Graph, ExportTemplate, Tag
from extras.scripts import BooleanVar, IntegerVar, Script, StringVar
from extras.utils import FeatureQuerySet
from tenancy.models import Tenant, TenantGroup
from utilities.testing import APITestCase, choices_to_dict
@ -35,7 +35,7 @@ class AppTest(APITestCase):
self.assertEqual(choices_to_dict(response.data.get('export-template:template_language')), TemplateLanguageChoices.as_dict())
# Graph
content_types = ContentType.objects.filter(GRAPH_MODELS)
content_types = ContentType.objects.filter(FeatureQuerySet('graphs').get_queryset())
graph_type_choices = {
"{}.{}".format(ct.app_label, ct.model): ct.name for ct in content_types
}

View File

@ -3,8 +3,8 @@ from django.test import TestCase
from dcim.models import DeviceRole, Platform, Region, Site
from extras.choices import *
from extras.constants import GRAPH_MODELS
from extras.filters import *
from extras.utils import FeatureQuerySet
from extras.models import ConfigContext, ExportTemplate, Graph
from tenancy.models import Tenant, TenantGroup
from virtualization.models import Cluster, ClusterGroup, ClusterType
@ -18,7 +18,7 @@ class GraphTestCase(TestCase):
def setUpTestData(cls):
# Get the first three available types
content_types = ContentType.objects.filter(GRAPH_MODELS)[:3]
content_types = ContentType.objects.filter(FeatureQuerySet('graphs').get_queryset())[:3]
graphs = (
Graph(name='Graph 1', type=content_types[0], template_language=TemplateLanguageChoices.LANGUAGE_DJANGO, source='http://example.com/1'),
@ -32,7 +32,7 @@ class GraphTestCase(TestCase):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_type(self):
content_type = ContentType.objects.filter(GRAPH_MODELS).first()
content_type = ContentType.objects.filter(FeatureQuerySet('graphs').get_queryset()).first()
params = {'type': content_type.pk}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)

View File

@ -1,6 +1,12 @@
import collections
from django.db.models import Q
from django.utils.deconstruct import deconstructible
from taggit.managers import _TaggableManager
from utilities.querysets import DummyQuerySet
from extras.constants import EXTRAS_FEATURES
def is_taggable(obj):
"""
@ -13,3 +19,65 @@ def is_taggable(obj):
if isinstance(obj.tags, DummyQuerySet):
return True
return False
#
# Dynamic feature registration
#
class Registry:
"""
The registry is a place to hook into for data storage across components
"""
def add_store(self, store_name, initial_value=None):
"""
Given the name of some new data parameter and an optional initial value, setup the registry store
"""
if not hasattr(Registry, store_name):
setattr(Registry, store_name, initial_value)
registry = Registry()
@deconstructible
class FeatureQuerySet:
"""
Helper class that delays evaluation of the registry contents for the functionaility store
until it has been populated.
"""
def __init__(self, feature):
self.feature = feature
def __call__(self):
return self.get_queryset()
def get_queryset(self):
"""
Given an extras feature, return a Q object for content type lookup
"""
query = Q()
for app_label, models in registry.model_feature_store[self.feature].items():
query |= Q(app_label=app_label, model__in=models)
return query
registry.add_store('model_feature_store', {f: collections.defaultdict(list) for f in EXTRAS_FEATURES})
def extras_features(*features):
"""
Decorator used to register extras provided features to a model
"""
def wrapper(model_class):
for feature in features:
if feature in EXTRAS_FEATURES:
app_label, model_name = model_class._meta.label_lower.split('.')
registry.model_feature_store[feature][app_label].append(model_name)
else:
raise ValueError('{} is not a valid extras feature!'.format(feature))
return model_class
return wrapper

View File

@ -8,6 +8,7 @@ from extras.models import Webhook
from utilities.api import get_serializer_for_model
from .choices import *
from .constants import *
from .utils import FeatureQuerySet
def generate_signature(request_body, secret):
@ -29,7 +30,7 @@ def enqueue_webhooks(instance, user, request_id, action):
"""
obj_type = ContentType.objects.get_for_model(instance.__class__)
webhook_models = ContentType.objects.filter(WEBHOOK_MODELS)
webhook_models = ContentType.objects.filter(FeatureQuerySet('webhooks').get_queryset())
if obj_type not in webhook_models:
return

View File

@ -10,6 +10,7 @@ from taggit.managers import TaggableManager
from dcim.models import Device, Interface
from extras.models import CustomFieldModel, ObjectChange, TaggedItem
from extras.utils import extras_features
from utilities.models import ChangeLoggedModel
from utilities.utils import serialize_object
from virtualization.models import VirtualMachine
@ -34,6 +35,7 @@ __all__ = (
)
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
class VRF(ChangeLoggedModel, CustomFieldModel):
"""
A virtual routing and forwarding (VRF) table represents a discrete layer three forwarding domain (e.g. a routing
@ -145,6 +147,7 @@ class RIR(ChangeLoggedModel):
)
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
class Aggregate(ChangeLoggedModel, CustomFieldModel):
"""
An aggregate exists at the root level of the IP address space hierarchy in NetBox. Aggregates are used to organize
@ -285,6 +288,7 @@ class Role(ChangeLoggedModel):
)
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
class Prefix(ChangeLoggedModel, CustomFieldModel):
"""
A Prefix represents an IPv4 or IPv6 network, including mask length. Prefixes can optionally be assigned to Sites and
@ -551,6 +555,7 @@ class Prefix(ChangeLoggedModel, CustomFieldModel):
return int(float(child_count) / prefix_size * 100)
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
class IPAddress(ChangeLoggedModel, CustomFieldModel):
"""
An IPAddress represents an individual IPv4 or IPv6 address and its mask. The mask length should match what is
@ -854,6 +859,7 @@ class VLANGroup(ChangeLoggedModel):
return None
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
class VLAN(ChangeLoggedModel, CustomFieldModel):
"""
A VLAN is a distinct layer two forwarding domain identified by a 12-bit integer (1-4094). Each VLAN must be assigned
@ -978,6 +984,7 @@ class VLAN(ChangeLoggedModel, CustomFieldModel):
).distinct()
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
class Service(ChangeLoggedModel, CustomFieldModel):
"""
A Service represents a layer-four service (e.g. HTTP or SSH) running on a Device or VirtualMachine. A Service may

View File

@ -16,6 +16,7 @@ from taggit.managers import TaggableManager
from dcim.models import Device
from extras.models import CustomFieldModel, TaggedItem
from extras.utils import extras_features
from utilities.models import ChangeLoggedModel
from .exceptions import InvalidKey
from .hashers import SecretValidationHasher
@ -295,6 +296,7 @@ class SecretRole(ChangeLoggedModel):
return user in self.users.all() or user.groups.filter(pk__in=self.groups.all()).exists()
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
class Secret(ChangeLoggedModel, CustomFieldModel):
"""
A Secret stores an AES256-encrypted copy of sensitive data, such as passwords or secret keys. An irreversible

View File

@ -4,6 +4,7 @@ from django.urls import reverse
from taggit.managers import TaggableManager
from extras.models import CustomFieldModel, TaggedItem
from extras.utils import extras_features
from utilities.models import ChangeLoggedModel
@ -43,6 +44,7 @@ class TenantGroup(ChangeLoggedModel):
)
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
class Tenant(ChangeLoggedModel, CustomFieldModel):
"""
A Tenant represents an organization served by the NetBox owner. This is typically a customer or an internal

View File

@ -7,6 +7,7 @@ from taggit.managers import TaggableManager
from dcim.models import Device
from extras.models import ConfigContextModel, CustomFieldModel, TaggedItem
from extras.utils import extras_features
from utilities.models import ChangeLoggedModel
from .choices import *
@ -91,6 +92,7 @@ class ClusterGroup(ChangeLoggedModel):
# Clusters
#
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
class Cluster(ChangeLoggedModel, CustomFieldModel):
"""
A cluster of VirtualMachines. Each Cluster may optionally be associated with one or more Devices.
@ -177,6 +179,7 @@ class Cluster(ChangeLoggedModel, CustomFieldModel):
# Virtual machines
#
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
class VirtualMachine(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
"""
A virtual machine which runs inside a Cluster.