فهرست منبع

Refactor virtualization forms

jeremystretch 4 سال پیش
والد
کامیت
db522f96be

+ 0 - 971
netbox/virtualization/forms.py

@@ -1,971 +0,0 @@
-from django import forms
-from django.contrib.contenttypes.models import ContentType
-from django.core.exceptions import ValidationError
-from django.utils.translation import gettext as _
-
-from dcim.choices import InterfaceModeChoices
-from dcim.constants import INTERFACE_MTU_MAX, INTERFACE_MTU_MIN
-from dcim.forms.models import INTERFACE_MODE_HELP_TEXT
-from dcim.forms.common import InterfaceCommonForm
-from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site, SiteGroup
-from extras.forms import (
-    AddRemoveTagsForm, CustomFieldModelBulkEditForm, CustomFieldModelCSVForm, CustomFieldModelForm,
-    CustomFieldModelFilterForm, CustomFieldsMixin, LocalConfigContextFilterForm,
-)
-from extras.models import Tag
-from ipam.models import IPAddress, VLAN, VLANGroup
-from tenancy.forms import TenancyFilterForm, TenancyForm
-from tenancy.models import Tenant
-from utilities.forms import (
-    add_blank_choice, BootstrapMixin, BulkEditNullBooleanSelect, BulkRenameForm, CommentField, ConfirmationForm,
-    CSVChoiceField, CSVModelChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField,
-    form_from_model, JSONField, SlugField, SmallTextarea, StaticSelect, StaticSelectMultiple, TagFilterField,
-    BOOLEAN_WITH_BLANK_CHOICES,
-)
-from .choices import *
-from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
-
-
-#
-# Cluster types
-#
-
-class ClusterTypeForm(BootstrapMixin, CustomFieldModelForm):
-    slug = SlugField()
-
-    class Meta:
-        model = ClusterType
-        fields = [
-            'name', 'slug', 'description',
-        ]
-
-
-class ClusterTypeCSVForm(CustomFieldModelCSVForm):
-    slug = SlugField()
-
-    class Meta:
-        model = ClusterType
-        fields = ('name', 'slug', 'description')
-
-
-class ClusterTypeBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=ClusterType.objects.all(),
-        widget=forms.MultipleHiddenInput
-    )
-    description = forms.CharField(
-        max_length=200,
-        required=False
-    )
-
-    class Meta:
-        nullable_fields = ['description']
-
-
-class ClusterTypeFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
-    model = ClusterType
-    field_groups = [
-        ['q'],
-    ]
-    q = forms.CharField(
-        required=False,
-        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
-        label=_('Search')
-    )
-
-
-#
-# Cluster groups
-#
-
-class ClusterGroupForm(BootstrapMixin, CustomFieldModelForm):
-    slug = SlugField()
-
-    class Meta:
-        model = ClusterGroup
-        fields = [
-            'name', 'slug', 'description',
-        ]
-
-
-class ClusterGroupCSVForm(CustomFieldModelCSVForm):
-    slug = SlugField()
-
-    class Meta:
-        model = ClusterGroup
-        fields = ('name', 'slug', 'description')
-
-
-class ClusterGroupBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=ClusterGroup.objects.all(),
-        widget=forms.MultipleHiddenInput
-    )
-    description = forms.CharField(
-        max_length=200,
-        required=False
-    )
-
-    class Meta:
-        nullable_fields = ['description']
-
-
-class ClusterGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
-    model = ClusterGroup
-    field_groups = [
-        ['q'],
-    ]
-    q = forms.CharField(
-        required=False,
-        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
-        label=_('Search')
-    )
-
-
-#
-# Clusters
-#
-
-class ClusterForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
-    type = DynamicModelChoiceField(
-        queryset=ClusterType.objects.all()
-    )
-    group = DynamicModelChoiceField(
-        queryset=ClusterGroup.objects.all(),
-        required=False
-    )
-    region = DynamicModelChoiceField(
-        queryset=Region.objects.all(),
-        required=False,
-        initial_params={
-            'sites': '$site'
-        }
-    )
-    site_group = DynamicModelChoiceField(
-        queryset=SiteGroup.objects.all(),
-        required=False,
-        initial_params={
-            'sites': '$site'
-        }
-    )
-    site = DynamicModelChoiceField(
-        queryset=Site.objects.all(),
-        required=False,
-        query_params={
-            'region_id': '$region',
-            'group_id': '$site_group',
-        }
-    )
-    comments = CommentField()
-    tags = DynamicModelMultipleChoiceField(
-        queryset=Tag.objects.all(),
-        required=False
-    )
-
-    class Meta:
-        model = Cluster
-        fields = (
-            'name', 'type', 'group', 'tenant', 'region', 'site_group', 'site', 'comments', 'tags',
-        )
-        fieldsets = (
-            ('Cluster', ('name', 'type', 'group', 'region', 'site_group', 'site', 'tags')),
-            ('Tenancy', ('tenant_group', 'tenant')),
-        )
-
-
-class ClusterCSVForm(CustomFieldModelCSVForm):
-    type = CSVModelChoiceField(
-        queryset=ClusterType.objects.all(),
-        to_field_name='name',
-        help_text='Type of cluster'
-    )
-    group = CSVModelChoiceField(
-        queryset=ClusterGroup.objects.all(),
-        to_field_name='name',
-        required=False,
-        help_text='Assigned cluster group'
-    )
-    site = CSVModelChoiceField(
-        queryset=Site.objects.all(),
-        to_field_name='name',
-        required=False,
-        help_text='Assigned site'
-    )
-    tenant = CSVModelChoiceField(
-        queryset=Tenant.objects.all(),
-        to_field_name='name',
-        required=False,
-        help_text='Assigned tenant'
-    )
-
-    class Meta:
-        model = Cluster
-        fields = ('name', 'type', 'group', 'site', 'comments')
-
-
-class ClusterBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=Cluster.objects.all(),
-        widget=forms.MultipleHiddenInput()
-    )
-    type = DynamicModelChoiceField(
-        queryset=ClusterType.objects.all(),
-        required=False
-    )
-    group = DynamicModelChoiceField(
-        queryset=ClusterGroup.objects.all(),
-        required=False
-    )
-    tenant = DynamicModelChoiceField(
-        queryset=Tenant.objects.all(),
-        required=False
-    )
-    region = DynamicModelChoiceField(
-        queryset=Region.objects.all(),
-        required=False,
-    )
-    site_group = DynamicModelChoiceField(
-        queryset=SiteGroup.objects.all(),
-        required=False,
-    )
-    site = DynamicModelChoiceField(
-        queryset=Site.objects.all(),
-        required=False,
-        query_params={
-            'region_id': '$region',
-            'group_id': '$site_group',
-        }
-    )
-    comments = CommentField(
-        widget=SmallTextarea,
-        label='Comments'
-    )
-
-    class Meta:
-        nullable_fields = [
-            'group', 'site', 'comments', 'tenant',
-        ]
-
-
-class ClusterFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
-    model = Cluster
-    field_order = [
-        'q', 'type_id', 'region_id', 'site_id', 'group_id', 'tenant_group_id', 'tenant_id',
-    ]
-    field_groups = [
-        ['q', 'tag'],
-        ['group_id', 'type_id'],
-        ['region_id', 'site_group_id', 'site_id'],
-        ['tenant_group_id', 'tenant_id'],
-    ]
-    q = forms.CharField(
-        required=False,
-        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
-        label=_('Search')
-    )
-    type_id = DynamicModelMultipleChoiceField(
-        queryset=ClusterType.objects.all(),
-        required=False,
-        label=_('Type'),
-        fetch_trigger='open'
-    )
-    region_id = DynamicModelMultipleChoiceField(
-        queryset=Region.objects.all(),
-        required=False,
-        label=_('Region'),
-        fetch_trigger='open'
-    )
-    site_group_id = DynamicModelMultipleChoiceField(
-        queryset=SiteGroup.objects.all(),
-        required=False,
-        label=_('Site group'),
-        fetch_trigger='open'
-    )
-    site_id = DynamicModelMultipleChoiceField(
-        queryset=Site.objects.all(),
-        required=False,
-        null_option='None',
-        query_params={
-            'region_id': '$region_id',
-            'site_group_id': '$site_group_id',
-        },
-        label=_('Site'),
-        fetch_trigger='open'
-    )
-    group_id = DynamicModelMultipleChoiceField(
-        queryset=ClusterGroup.objects.all(),
-        required=False,
-        null_option='None',
-        label=_('Group'),
-        fetch_trigger='open'
-    )
-    tag = TagFilterField(model)
-
-
-class ClusterAddDevicesForm(BootstrapMixin, forms.Form):
-    region = DynamicModelChoiceField(
-        queryset=Region.objects.all(),
-        required=False,
-        null_option='None'
-    )
-    site_group = DynamicModelChoiceField(
-        queryset=SiteGroup.objects.all(),
-        required=False,
-        null_option='None'
-    )
-    site = DynamicModelChoiceField(
-        queryset=Site.objects.all(),
-        required=False,
-        query_params={
-            'region_id': '$region',
-            'group_id': '$site_group',
-        }
-    )
-    rack = DynamicModelChoiceField(
-        queryset=Rack.objects.all(),
-        required=False,
-        null_option='None',
-        query_params={
-            'site_id': '$site'
-        }
-    )
-    devices = DynamicModelMultipleChoiceField(
-        queryset=Device.objects.all(),
-        query_params={
-            'site_id': '$site',
-            'rack_id': '$rack',
-            'cluster_id': 'null',
-        }
-    )
-
-    class Meta:
-        fields = [
-            'region', 'site', 'rack', 'devices',
-        ]
-
-    def __init__(self, cluster, *args, **kwargs):
-
-        self.cluster = cluster
-
-        super().__init__(*args, **kwargs)
-
-        self.fields['devices'].choices = []
-
-    def clean(self):
-        super().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()
-    )
-
-
-#
-# Virtual Machines
-#
-
-class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
-    cluster_group = DynamicModelChoiceField(
-        queryset=ClusterGroup.objects.all(),
-        required=False,
-        null_option='None',
-        initial_params={
-            'clusters': '$cluster'
-        }
-    )
-    cluster = DynamicModelChoiceField(
-        queryset=Cluster.objects.all(),
-        query_params={
-            'group_id': '$cluster_group'
-        }
-    )
-    role = DynamicModelChoiceField(
-        queryset=DeviceRole.objects.all(),
-        required=False,
-        query_params={
-            "vm_role": "True"
-        }
-    )
-    platform = DynamicModelChoiceField(
-        queryset=Platform.objects.all(),
-        required=False
-    )
-    local_context_data = JSONField(
-        required=False,
-        label=''
-    )
-    tags = DynamicModelMultipleChoiceField(
-        queryset=Tag.objects.all(),
-        required=False
-    )
-
-    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',
-        ]
-        fieldsets = (
-            ('Virtual Machine', ('name', 'role', 'status', 'tags')),
-            ('Cluster', ('cluster_group', 'cluster')),
-            ('Tenancy', ('tenant_group', 'tenant')),
-            ('Management', ('platform', 'primary_ip4', 'primary_ip6')),
-            ('Resources', ('vcpus', 'memory', 'disk')),
-            ('Config Context', ('local_context_data',)),
-        )
-        help_texts = {
-            'local_context_data': "Local config context data overwrites all sources contexts in the final rendered "
-                                  "config context",
-        }
-        widgets = {
-            "status": StaticSelect(),
-            'primary_ip4': StaticSelect(),
-            'primary_ip6': StaticSelect(),
-        }
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-
-        if self.instance.pk:
-
-            # Compile list of choices for primary IPv4 and IPv6 addresses
-            for family in [4, 6]:
-                ip_choices = [(None, '---------')]
-
-                # Gather PKs of all interfaces belonging to this VM
-                interface_ids = self.instance.interfaces.values_list('pk', flat=True)
-
-                # Collect interface IPs
-                interface_ips = IPAddress.objects.filter(
-                    address__family=family,
-                    assigned_object_type=ContentType.objects.get_for_model(VMInterface),
-                    assigned_object_id__in=interface_ids
-                )
-                if interface_ips:
-                    ip_list = [(ip.id, f'{ip.address} ({ip.assigned_object})') for ip in interface_ips]
-                    ip_choices.append(('Interface IPs', ip_list))
-                # Collect NAT IPs
-                nat_ips = IPAddress.objects.prefetch_related('nat_inside').filter(
-                    address__family=family,
-                    nat_inside__assigned_object_type=ContentType.objects.get_for_model(VMInterface),
-                    nat_inside__assigned_object_id__in=interface_ids
-                )
-                if nat_ips:
-                    ip_list = [(ip.id, f'{ip.address} (NAT)') for ip in nat_ips]
-                    ip_choices.append(('NAT IPs', ip_list))
-                self.fields['primary_ip{}'.format(family)].choices = ip_choices
-
-        else:
-
-            # An object that doesn't exist yet can't have any IPs assigned to it
-            self.fields['primary_ip4'].choices = []
-            self.fields['primary_ip4'].widget.attrs['readonly'] = True
-            self.fields['primary_ip6'].choices = []
-            self.fields['primary_ip6'].widget.attrs['readonly'] = True
-
-
-class VirtualMachineCSVForm(CustomFieldModelCSVForm):
-    status = CSVChoiceField(
-        choices=VirtualMachineStatusChoices,
-        required=False,
-        help_text='Operational status of device'
-    )
-    cluster = CSVModelChoiceField(
-        queryset=Cluster.objects.all(),
-        to_field_name='name',
-        help_text='Assigned cluster'
-    )
-    role = CSVModelChoiceField(
-        queryset=DeviceRole.objects.filter(
-            vm_role=True
-        ),
-        required=False,
-        to_field_name='name',
-        help_text='Functional role'
-    )
-    tenant = CSVModelChoiceField(
-        queryset=Tenant.objects.all(),
-        required=False,
-        to_field_name='name',
-        help_text='Assigned tenant'
-    )
-    platform = CSVModelChoiceField(
-        queryset=Platform.objects.all(),
-        required=False,
-        to_field_name='name',
-        help_text='Assigned platform'
-    )
-
-    class Meta:
-        model = VirtualMachine
-        fields = (
-            'name', 'status', 'role', 'cluster', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments',
-        )
-
-
-class VirtualMachineBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=VirtualMachine.objects.all(),
-        widget=forms.MultipleHiddenInput()
-    )
-    status = forms.ChoiceField(
-        choices=add_blank_choice(VirtualMachineStatusChoices),
-        required=False,
-        initial='',
-        widget=StaticSelect(),
-    )
-    cluster = DynamicModelChoiceField(
-        queryset=Cluster.objects.all(),
-        required=False
-    )
-    role = DynamicModelChoiceField(
-        queryset=DeviceRole.objects.filter(
-            vm_role=True
-        ),
-        required=False,
-        query_params={
-            "vm_role": "True"
-        }
-    )
-    tenant = DynamicModelChoiceField(
-        queryset=Tenant.objects.all(),
-        required=False
-    )
-    platform = DynamicModelChoiceField(
-        queryset=Platform.objects.all(),
-        required=False
-    )
-    vcpus = forms.IntegerField(
-        required=False,
-        label='vCPUs'
-    )
-    memory = forms.IntegerField(
-        required=False,
-        label='Memory (MB)'
-    )
-    disk = forms.IntegerField(
-        required=False,
-        label='Disk (GB)'
-    )
-    comments = CommentField(
-        widget=SmallTextarea,
-        label='Comments'
-    )
-
-    class Meta:
-        nullable_fields = [
-            'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments',
-        ]
-
-
-class VirtualMachineFilterForm(
-    BootstrapMixin,
-    LocalConfigContextFilterForm,
-    TenancyFilterForm,
-    CustomFieldModelFilterForm
-):
-    model = VirtualMachine
-    field_groups = [
-        ['q', 'tag'],
-        ['cluster_group_id', 'cluster_type_id', 'cluster_id'],
-        ['region_id', 'site_group_id', 'site_id'],
-        ['status', 'role_id', 'platform_id', 'mac_address', 'has_primary_ip', 'local_context_data'],
-        ['tenant_group_id', 'tenant_id'],
-    ]
-    q = forms.CharField(
-        required=False,
-        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
-        label=_('Search')
-    )
-    cluster_group_id = DynamicModelMultipleChoiceField(
-        queryset=ClusterGroup.objects.all(),
-        required=False,
-        null_option='None',
-        label=_('Cluster group'),
-        fetch_trigger='open'
-    )
-    cluster_type_id = DynamicModelMultipleChoiceField(
-        queryset=ClusterType.objects.all(),
-        required=False,
-        null_option='None',
-        label=_('Cluster type'),
-        fetch_trigger='open'
-    )
-    cluster_id = DynamicModelMultipleChoiceField(
-        queryset=Cluster.objects.all(),
-        required=False,
-        label=_('Cluster'),
-        fetch_trigger='open'
-    )
-    region_id = DynamicModelMultipleChoiceField(
-        queryset=Region.objects.all(),
-        required=False,
-        label=_('Region'),
-        fetch_trigger='open'
-    )
-    site_group_id = DynamicModelMultipleChoiceField(
-        queryset=SiteGroup.objects.all(),
-        required=False,
-        label=_('Site group'),
-        fetch_trigger='open'
-    )
-    site_id = DynamicModelMultipleChoiceField(
-        queryset=Site.objects.all(),
-        required=False,
-        null_option='None',
-        query_params={
-            'region_id': '$region_id',
-            'group_id': '$site_group_id',
-        },
-        label=_('Site'),
-        fetch_trigger='open'
-    )
-    role_id = DynamicModelMultipleChoiceField(
-        queryset=DeviceRole.objects.all(),
-        required=False,
-        null_option='None',
-        query_params={
-            'vm_role': "True"
-        },
-        label=_('Role'),
-        fetch_trigger='open'
-    )
-    status = forms.MultipleChoiceField(
-        choices=VirtualMachineStatusChoices,
-        required=False,
-        widget=StaticSelectMultiple()
-    )
-    platform_id = DynamicModelMultipleChoiceField(
-        queryset=Platform.objects.all(),
-        required=False,
-        null_option='None',
-        label=_('Platform'),
-        fetch_trigger='open'
-    )
-    mac_address = forms.CharField(
-        required=False,
-        label='MAC address'
-    )
-    has_primary_ip = forms.NullBooleanField(
-        required=False,
-        label='Has a primary IP',
-        widget=StaticSelect(
-            choices=BOOLEAN_WITH_BLANK_CHOICES
-        )
-    )
-    tag = TagFilterField(model)
-
-
-#
-# VM interfaces
-#
-
-class VMInterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm):
-    parent = DynamicModelChoiceField(
-        queryset=VMInterface.objects.all(),
-        required=False,
-        label='Parent interface'
-    )
-    vlan_group = DynamicModelChoiceField(
-        queryset=VLANGroup.objects.all(),
-        required=False,
-        label='VLAN group'
-    )
-    untagged_vlan = DynamicModelChoiceField(
-        queryset=VLAN.objects.all(),
-        required=False,
-        label='Untagged VLAN',
-        query_params={
-            'group_id': '$vlan_group',
-        }
-    )
-    tagged_vlans = DynamicModelMultipleChoiceField(
-        queryset=VLAN.objects.all(),
-        required=False,
-        label='Tagged VLANs',
-        query_params={
-            'group_id': '$vlan_group',
-        }
-    )
-    tags = DynamicModelMultipleChoiceField(
-        queryset=Tag.objects.all(),
-        required=False
-    )
-
-    class Meta:
-        model = VMInterface
-        fields = [
-            'virtual_machine', 'name', 'enabled', 'parent', 'mac_address', 'mtu', 'description', 'mode', 'tags',
-            'untagged_vlan', 'tagged_vlans',
-        ]
-        widgets = {
-            'virtual_machine': forms.HiddenInput(),
-            'mode': StaticSelect()
-        }
-        labels = {
-            'mode': '802.1Q Mode',
-        }
-        help_texts = {
-            'mode': INTERFACE_MODE_HELP_TEXT,
-        }
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-        vm_id = self.initial.get('virtual_machine') or self.data.get('virtual_machine')
-
-        # Restrict parent interface assignment by VM
-        self.fields['parent'].widget.add_query_param('virtual_machine_id', vm_id)
-
-        # Limit VLAN choices by virtual machine
-        self.fields['untagged_vlan'].widget.add_query_param('available_on_virtualmachine', vm_id)
-        self.fields['tagged_vlans'].widget.add_query_param('available_on_virtualmachine', vm_id)
-
-
-class VMInterfaceCreateForm(BootstrapMixin, CustomFieldsMixin, InterfaceCommonForm):
-    model = VMInterface
-    virtual_machine = DynamicModelChoiceField(
-        queryset=VirtualMachine.objects.all()
-    )
-    name_pattern = ExpandableNameField(
-        label='Name'
-    )
-    enabled = forms.BooleanField(
-        required=False,
-        initial=True
-    )
-    parent = DynamicModelChoiceField(
-        queryset=VMInterface.objects.all(),
-        required=False,
-        query_params={
-            'virtual_machine_id': '$virtual_machine',
-        }
-    )
-    mac_address = forms.CharField(
-        required=False,
-        label='MAC Address'
-    )
-    description = forms.CharField(
-        max_length=200,
-        required=False
-    )
-    mode = forms.ChoiceField(
-        choices=add_blank_choice(InterfaceModeChoices),
-        required=False,
-        widget=StaticSelect(),
-    )
-    untagged_vlan = DynamicModelChoiceField(
-        queryset=VLAN.objects.all(),
-        required=False
-    )
-    tagged_vlans = DynamicModelMultipleChoiceField(
-        queryset=VLAN.objects.all(),
-        required=False
-    )
-    tags = DynamicModelMultipleChoiceField(
-        queryset=Tag.objects.all(),
-        required=False
-    )
-    field_order = (
-        'virtual_machine', 'name_pattern', 'enabled', 'parent', 'mtu', 'mac_address', 'description', 'mode',
-        'untagged_vlan', 'tagged_vlans', 'tags'
-    )
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-        vm_id = self.initial.get('virtual_machine') or self.data.get('virtual_machine')
-
-        # Limit VLAN choices by virtual machine
-        self.fields['untagged_vlan'].widget.add_query_param('available_on_virtualmachine', vm_id)
-        self.fields['tagged_vlans'].widget.add_query_param('available_on_virtualmachine', vm_id)
-
-
-class VMInterfaceCSVForm(CustomFieldModelCSVForm):
-    virtual_machine = CSVModelChoiceField(
-        queryset=VirtualMachine.objects.all(),
-        to_field_name='name'
-    )
-    mode = CSVChoiceField(
-        choices=InterfaceModeChoices,
-        required=False,
-        help_text='IEEE 802.1Q operational mode (for L2 interfaces)'
-    )
-
-    class Meta:
-        model = VMInterface
-        fields = (
-            'virtual_machine', 'name', 'enabled', 'mac_address', 'mtu', 'description', 'mode',
-        )
-
-    def clean_enabled(self):
-        # Make sure enabled is True when it's not included in the uploaded data
-        if 'enabled' not in self.data:
-            return True
-        else:
-            return self.cleaned_data['enabled']
-
-
-class VMInterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=VMInterface.objects.all(),
-        widget=forms.MultipleHiddenInput()
-    )
-    virtual_machine = forms.ModelChoiceField(
-        queryset=VirtualMachine.objects.all(),
-        required=False,
-        disabled=True,
-        widget=forms.HiddenInput()
-    )
-    parent = DynamicModelChoiceField(
-        queryset=VMInterface.objects.all(),
-        required=False
-    )
-    enabled = forms.NullBooleanField(
-        required=False,
-        widget=BulkEditNullBooleanSelect()
-    )
-    mtu = forms.IntegerField(
-        required=False,
-        min_value=INTERFACE_MTU_MIN,
-        max_value=INTERFACE_MTU_MAX,
-        label='MTU'
-    )
-    description = forms.CharField(
-        max_length=100,
-        required=False
-    )
-    mode = forms.ChoiceField(
-        choices=add_blank_choice(InterfaceModeChoices),
-        required=False,
-        widget=StaticSelect()
-    )
-    untagged_vlan = DynamicModelChoiceField(
-        queryset=VLAN.objects.all(),
-        required=False
-    )
-    tagged_vlans = DynamicModelMultipleChoiceField(
-        queryset=VLAN.objects.all(),
-        required=False
-    )
-
-    class Meta:
-        nullable_fields = [
-            'parent', 'mtu', 'description',
-        ]
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-        if 'virtual_machine' in self.initial:
-            vm_id = self.initial.get('virtual_machine')
-
-            # Restrict parent interface assignment by VM
-            self.fields['parent'].widget.add_query_param('virtual_machine_id', vm_id)
-
-            # Limit VLAN choices by virtual machine
-            self.fields['untagged_vlan'].widget.add_query_param('available_on_virtualmachine', vm_id)
-            self.fields['tagged_vlans'].widget.add_query_param('available_on_virtualmachine', vm_id)
-
-        else:
-            # See 5643
-            if 'pk' in self.initial:
-                site = None
-                interfaces = VMInterface.objects.filter(pk__in=self.initial['pk']).prefetch_related(
-                    'virtual_machine__cluster__site'
-                )
-
-                # Check interface sites.  First interface should set site, further interfaces will either continue the
-                # loop or reset back to no site and break the loop.
-                for interface in interfaces:
-                    if site is None:
-                        site = interface.virtual_machine.cluster.site
-                    elif interface.virtual_machine.cluster.site is not site:
-                        site = None
-                        break
-
-                if site is not None:
-                    self.fields['untagged_vlan'].widget.add_query_param('site_id', site.pk)
-                    self.fields['tagged_vlans'].widget.add_query_param('site_id', site.pk)
-
-
-class VMInterfaceBulkRenameForm(BulkRenameForm):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=VMInterface.objects.all(),
-        widget=forms.MultipleHiddenInput()
-    )
-
-
-class VMInterfaceFilterForm(BootstrapMixin, forms.Form):
-    model = VMInterface
-    field_groups = [
-        ['q', 'tag'],
-        ['cluster_id', 'virtual_machine_id'],
-        ['enabled', 'mac_address'],
-    ]
-    q = forms.CharField(
-        required=False,
-        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
-        label=_('Search')
-    )
-    cluster_id = DynamicModelMultipleChoiceField(
-        queryset=Cluster.objects.all(),
-        required=False,
-        label=_('Cluster'),
-        fetch_trigger='open'
-    )
-    virtual_machine_id = DynamicModelMultipleChoiceField(
-        queryset=VirtualMachine.objects.all(),
-        required=False,
-        query_params={
-            'cluster_id': '$cluster_id'
-        },
-        label=_('Virtual machine'),
-        fetch_trigger='open'
-    )
-    enabled = forms.NullBooleanField(
-        required=False,
-        widget=StaticSelect(
-            choices=BOOLEAN_WITH_BLANK_CHOICES
-        )
-    )
-    mac_address = forms.CharField(
-        required=False,
-        label='MAC address'
-    )
-    tag = TagFilterField(model)
-
-
-#
-# Bulk VirtualMachine component creation
-#
-
-class VirtualMachineBulkAddComponentForm(BootstrapMixin, forms.Form):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=VirtualMachine.objects.all(),
-        widget=forms.MultipleHiddenInput()
-    )
-    name_pattern = ExpandableNameField(
-        label='Name'
-    )
-
-    def clean_tags(self):
-        # Because we're feeding TagField data (on the bulk edit form) to another TagField (on the model form), we
-        # must first convert the list of tags to a string.
-        return ','.join(self.cleaned_data.get('tags'))
-
-
-class VMInterfaceBulkCreateForm(
-    form_from_model(VMInterface, ['enabled', 'mtu', 'description', 'tags']),
-    VirtualMachineBulkAddComponentForm
-):
-    pass

+ 6 - 0
netbox/virtualization/forms/__init__.py

@@ -0,0 +1,6 @@
+from .models import *
+from .filtersets import *
+from .object_create import *
+from .bulk_create import *
+from .bulk_edit import *
+from .bulk_import import *

+ 30 - 0
netbox/virtualization/forms/bulk_create.py

@@ -0,0 +1,30 @@
+from django import forms
+
+from utilities.forms import BootstrapMixin, ExpandableNameField, form_from_model
+from virtualization.models import VMInterface, VirtualMachine
+
+__all__ = (
+    'VMInterfaceBulkCreateForm',
+)
+
+
+class VirtualMachineBulkAddComponentForm(BootstrapMixin, forms.Form):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=VirtualMachine.objects.all(),
+        widget=forms.MultipleHiddenInput()
+    )
+    name_pattern = ExpandableNameField(
+        label='Name'
+    )
+
+    def clean_tags(self):
+        # Because we're feeding TagField data (on the bulk edit form) to another TagField (on the model form), we
+        # must first convert the list of tags to a string.
+        return ','.join(self.cleaned_data.get('tags'))
+
+
+class VMInterfaceBulkCreateForm(
+    form_from_model(VMInterface, ['enabled', 'mtu', 'description', 'tags']),
+    VirtualMachineBulkAddComponentForm
+):
+    pass

+ 239 - 0
netbox/virtualization/forms/bulk_edit.py

@@ -0,0 +1,239 @@
+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 extras.forms import AddRemoveTagsForm, CustomFieldModelBulkEditForm
+from ipam.models import VLAN
+from tenancy.models import Tenant
+from utilities.forms import (
+    add_blank_choice, BootstrapMixin, BulkEditNullBooleanSelect, BulkRenameForm, CommentField, DynamicModelChoiceField,
+    DynamicModelMultipleChoiceField, SmallTextarea, StaticSelect
+)
+from virtualization.choices import *
+from virtualization.models import *
+
+__all__ = (
+    'ClusterBulkEditForm',
+    'ClusterGroupBulkEditForm',
+    'ClusterTypeBulkEditForm',
+    'VirtualMachineBulkEditForm',
+    'VMInterfaceBulkEditForm',
+    'VMInterfaceBulkRenameForm',
+)
+
+
+class ClusterTypeBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=ClusterType.objects.all(),
+        widget=forms.MultipleHiddenInput
+    )
+    description = forms.CharField(
+        max_length=200,
+        required=False
+    )
+
+    class Meta:
+        nullable_fields = ['description']
+
+
+class ClusterGroupBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=ClusterGroup.objects.all(),
+        widget=forms.MultipleHiddenInput
+    )
+    description = forms.CharField(
+        max_length=200,
+        required=False
+    )
+
+    class Meta:
+        nullable_fields = ['description']
+
+
+class ClusterBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=Cluster.objects.all(),
+        widget=forms.MultipleHiddenInput()
+    )
+    type = DynamicModelChoiceField(
+        queryset=ClusterType.objects.all(),
+        required=False
+    )
+    group = DynamicModelChoiceField(
+        queryset=ClusterGroup.objects.all(),
+        required=False
+    )
+    tenant = DynamicModelChoiceField(
+        queryset=Tenant.objects.all(),
+        required=False
+    )
+    region = DynamicModelChoiceField(
+        queryset=Region.objects.all(),
+        required=False,
+    )
+    site_group = DynamicModelChoiceField(
+        queryset=SiteGroup.objects.all(),
+        required=False,
+    )
+    site = DynamicModelChoiceField(
+        queryset=Site.objects.all(),
+        required=False,
+        query_params={
+            'region_id': '$region',
+            'group_id': '$site_group',
+        }
+    )
+    comments = CommentField(
+        widget=SmallTextarea,
+        label='Comments'
+    )
+
+    class Meta:
+        nullable_fields = [
+            'group', 'site', 'comments', 'tenant',
+        ]
+
+
+class VirtualMachineBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=VirtualMachine.objects.all(),
+        widget=forms.MultipleHiddenInput()
+    )
+    status = forms.ChoiceField(
+        choices=add_blank_choice(VirtualMachineStatusChoices),
+        required=False,
+        initial='',
+        widget=StaticSelect(),
+    )
+    cluster = DynamicModelChoiceField(
+        queryset=Cluster.objects.all(),
+        required=False
+    )
+    role = DynamicModelChoiceField(
+        queryset=DeviceRole.objects.filter(
+            vm_role=True
+        ),
+        required=False,
+        query_params={
+            "vm_role": "True"
+        }
+    )
+    tenant = DynamicModelChoiceField(
+        queryset=Tenant.objects.all(),
+        required=False
+    )
+    platform = DynamicModelChoiceField(
+        queryset=Platform.objects.all(),
+        required=False
+    )
+    vcpus = forms.IntegerField(
+        required=False,
+        label='vCPUs'
+    )
+    memory = forms.IntegerField(
+        required=False,
+        label='Memory (MB)'
+    )
+    disk = forms.IntegerField(
+        required=False,
+        label='Disk (GB)'
+    )
+    comments = CommentField(
+        widget=SmallTextarea,
+        label='Comments'
+    )
+
+    class Meta:
+        nullable_fields = [
+            'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments',
+        ]
+
+
+class VMInterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=VMInterface.objects.all(),
+        widget=forms.MultipleHiddenInput()
+    )
+    virtual_machine = forms.ModelChoiceField(
+        queryset=VirtualMachine.objects.all(),
+        required=False,
+        disabled=True,
+        widget=forms.HiddenInput()
+    )
+    parent = DynamicModelChoiceField(
+        queryset=VMInterface.objects.all(),
+        required=False
+    )
+    enabled = forms.NullBooleanField(
+        required=False,
+        widget=BulkEditNullBooleanSelect()
+    )
+    mtu = forms.IntegerField(
+        required=False,
+        min_value=INTERFACE_MTU_MIN,
+        max_value=INTERFACE_MTU_MAX,
+        label='MTU'
+    )
+    description = forms.CharField(
+        max_length=100,
+        required=False
+    )
+    mode = forms.ChoiceField(
+        choices=add_blank_choice(InterfaceModeChoices),
+        required=False,
+        widget=StaticSelect()
+    )
+    untagged_vlan = DynamicModelChoiceField(
+        queryset=VLAN.objects.all(),
+        required=False
+    )
+    tagged_vlans = DynamicModelMultipleChoiceField(
+        queryset=VLAN.objects.all(),
+        required=False
+    )
+
+    class Meta:
+        nullable_fields = [
+            'parent', 'mtu', 'description',
+        ]
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        if 'virtual_machine' in self.initial:
+            vm_id = self.initial.get('virtual_machine')
+
+            # Restrict parent interface assignment by VM
+            self.fields['parent'].widget.add_query_param('virtual_machine_id', vm_id)
+
+            # Limit VLAN choices by virtual machine
+            self.fields['untagged_vlan'].widget.add_query_param('available_on_virtualmachine', vm_id)
+            self.fields['tagged_vlans'].widget.add_query_param('available_on_virtualmachine', vm_id)
+
+        else:
+            # See 5643
+            if 'pk' in self.initial:
+                site = None
+                interfaces = VMInterface.objects.filter(pk__in=self.initial['pk']).prefetch_related(
+                    'virtual_machine__cluster__site'
+                )
+
+                # Check interface sites.  First interface should set site, further interfaces will either continue the
+                # loop or reset back to no site and break the loop.
+                for interface in interfaces:
+                    if site is None:
+                        site = interface.virtual_machine.cluster.site
+                    elif interface.virtual_machine.cluster.site is not site:
+                        site = None
+                        break
+
+                if site is not None:
+                    self.fields['untagged_vlan'].widget.add_query_param('site_id', site.pk)
+                    self.fields['tagged_vlans'].widget.add_query_param('site_id', site.pk)
+
+
+class VMInterfaceBulkRenameForm(BulkRenameForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=VMInterface.objects.all(),
+        widget=forms.MultipleHiddenInput()
+    )

+ 125 - 0
netbox/virtualization/forms/bulk_import.py

@@ -0,0 +1,125 @@
+from dcim.choices import InterfaceModeChoices
+from dcim.models import DeviceRole, Platform, Site
+from extras.forms import CustomFieldModelCSVForm
+from tenancy.models import Tenant
+from utilities.forms import CSVChoiceField, CSVModelChoiceField, SlugField
+from virtualization.choices import *
+from virtualization.models import *
+
+__all__ = (
+    'ClusterCSVForm',
+    'ClusterGroupCSVForm',
+    'ClusterTypeCSVForm',
+    'VirtualMachineCSVForm',
+    'VMInterfaceCSVForm',
+)
+
+
+class ClusterTypeCSVForm(CustomFieldModelCSVForm):
+    slug = SlugField()
+
+    class Meta:
+        model = ClusterType
+        fields = ('name', 'slug', 'description')
+
+
+class ClusterGroupCSVForm(CustomFieldModelCSVForm):
+    slug = SlugField()
+
+    class Meta:
+        model = ClusterGroup
+        fields = ('name', 'slug', 'description')
+
+
+class ClusterCSVForm(CustomFieldModelCSVForm):
+    type = CSVModelChoiceField(
+        queryset=ClusterType.objects.all(),
+        to_field_name='name',
+        help_text='Type of cluster'
+    )
+    group = CSVModelChoiceField(
+        queryset=ClusterGroup.objects.all(),
+        to_field_name='name',
+        required=False,
+        help_text='Assigned cluster group'
+    )
+    site = CSVModelChoiceField(
+        queryset=Site.objects.all(),
+        to_field_name='name',
+        required=False,
+        help_text='Assigned site'
+    )
+    tenant = CSVModelChoiceField(
+        queryset=Tenant.objects.all(),
+        to_field_name='name',
+        required=False,
+        help_text='Assigned tenant'
+    )
+
+    class Meta:
+        model = Cluster
+        fields = ('name', 'type', 'group', 'site', 'comments')
+
+
+class VirtualMachineCSVForm(CustomFieldModelCSVForm):
+    status = CSVChoiceField(
+        choices=VirtualMachineStatusChoices,
+        required=False,
+        help_text='Operational status of device'
+    )
+    cluster = CSVModelChoiceField(
+        queryset=Cluster.objects.all(),
+        to_field_name='name',
+        help_text='Assigned cluster'
+    )
+    role = CSVModelChoiceField(
+        queryset=DeviceRole.objects.filter(
+            vm_role=True
+        ),
+        required=False,
+        to_field_name='name',
+        help_text='Functional role'
+    )
+    tenant = CSVModelChoiceField(
+        queryset=Tenant.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text='Assigned tenant'
+    )
+    platform = CSVModelChoiceField(
+        queryset=Platform.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text='Assigned platform'
+    )
+
+    class Meta:
+        model = VirtualMachine
+        fields = (
+            'name', 'status', 'role', 'cluster', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments',
+        )
+
+
+class VMInterfaceCSVForm(CustomFieldModelCSVForm):
+    virtual_machine = CSVModelChoiceField(
+        queryset=VirtualMachine.objects.all(),
+        to_field_name='name'
+    )
+    mode = CSVChoiceField(
+        choices=InterfaceModeChoices,
+        required=False,
+        help_text='IEEE 802.1Q operational mode (for L2 interfaces)'
+    )
+
+    class Meta:
+        model = VMInterface
+        fields = (
+            'virtual_machine', 'name', 'enabled', 'mac_address', 'mtu', 'description', 'mode',
+        )
+
+    def clean_enabled(self):
+        # Make sure enabled is True when it's not included in the uploaded data
+        if 'enabled' not in self.data:
+            return True
+        else:
+            return self.cleaned_data['enabled']

+ 237 - 0
netbox/virtualization/forms/filtersets.py

@@ -0,0 +1,237 @@
+from django import forms
+from django.utils.translation import gettext as _
+
+from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup
+from extras.forms import CustomFieldModelFilterForm, LocalConfigContextFilterForm
+from tenancy.forms import TenancyFilterForm
+from utilities.forms import (
+    BootstrapMixin, DynamicModelMultipleChoiceField, StaticSelect, StaticSelectMultiple, TagFilterField,
+    BOOLEAN_WITH_BLANK_CHOICES,
+)
+from virtualization.choices import *
+from virtualization.models import *
+
+__all__ = (
+    'ClusterFilterForm',
+    'ClusterGroupFilterForm',
+    'ClusterTypeFilterForm',
+    'VirtualMachineFilterForm',
+    'VMInterfaceFilterForm',
+)
+
+
+class ClusterTypeFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
+    model = ClusterType
+    field_groups = [
+        ['q'],
+    ]
+    q = forms.CharField(
+        required=False,
+        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
+        label=_('Search')
+    )
+
+
+class ClusterGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
+    model = ClusterGroup
+    field_groups = [
+        ['q'],
+    ]
+    q = forms.CharField(
+        required=False,
+        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
+        label=_('Search')
+    )
+
+
+class ClusterFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
+    model = Cluster
+    field_order = [
+        'q', 'type_id', 'region_id', 'site_id', 'group_id', 'tenant_group_id', 'tenant_id',
+    ]
+    field_groups = [
+        ['q', 'tag'],
+        ['group_id', 'type_id'],
+        ['region_id', 'site_group_id', 'site_id'],
+        ['tenant_group_id', 'tenant_id'],
+    ]
+    q = forms.CharField(
+        required=False,
+        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
+        label=_('Search')
+    )
+    type_id = DynamicModelMultipleChoiceField(
+        queryset=ClusterType.objects.all(),
+        required=False,
+        label=_('Type'),
+        fetch_trigger='open'
+    )
+    region_id = DynamicModelMultipleChoiceField(
+        queryset=Region.objects.all(),
+        required=False,
+        label=_('Region'),
+        fetch_trigger='open'
+    )
+    site_group_id = DynamicModelMultipleChoiceField(
+        queryset=SiteGroup.objects.all(),
+        required=False,
+        label=_('Site group'),
+        fetch_trigger='open'
+    )
+    site_id = DynamicModelMultipleChoiceField(
+        queryset=Site.objects.all(),
+        required=False,
+        null_option='None',
+        query_params={
+            'region_id': '$region_id',
+            'site_group_id': '$site_group_id',
+        },
+        label=_('Site'),
+        fetch_trigger='open'
+    )
+    group_id = DynamicModelMultipleChoiceField(
+        queryset=ClusterGroup.objects.all(),
+        required=False,
+        null_option='None',
+        label=_('Group'),
+        fetch_trigger='open'
+    )
+    tag = TagFilterField(model)
+
+
+class VirtualMachineFilterForm(
+    BootstrapMixin,
+    LocalConfigContextFilterForm,
+    TenancyFilterForm,
+    CustomFieldModelFilterForm
+):
+    model = VirtualMachine
+    field_groups = [
+        ['q', 'tag'],
+        ['cluster_group_id', 'cluster_type_id', 'cluster_id'],
+        ['region_id', 'site_group_id', 'site_id'],
+        ['status', 'role_id', 'platform_id', 'mac_address', 'has_primary_ip', 'local_context_data'],
+        ['tenant_group_id', 'tenant_id'],
+    ]
+    q = forms.CharField(
+        required=False,
+        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
+        label=_('Search')
+    )
+    cluster_group_id = DynamicModelMultipleChoiceField(
+        queryset=ClusterGroup.objects.all(),
+        required=False,
+        null_option='None',
+        label=_('Cluster group'),
+        fetch_trigger='open'
+    )
+    cluster_type_id = DynamicModelMultipleChoiceField(
+        queryset=ClusterType.objects.all(),
+        required=False,
+        null_option='None',
+        label=_('Cluster type'),
+        fetch_trigger='open'
+    )
+    cluster_id = DynamicModelMultipleChoiceField(
+        queryset=Cluster.objects.all(),
+        required=False,
+        label=_('Cluster'),
+        fetch_trigger='open'
+    )
+    region_id = DynamicModelMultipleChoiceField(
+        queryset=Region.objects.all(),
+        required=False,
+        label=_('Region'),
+        fetch_trigger='open'
+    )
+    site_group_id = DynamicModelMultipleChoiceField(
+        queryset=SiteGroup.objects.all(),
+        required=False,
+        label=_('Site group'),
+        fetch_trigger='open'
+    )
+    site_id = DynamicModelMultipleChoiceField(
+        queryset=Site.objects.all(),
+        required=False,
+        null_option='None',
+        query_params={
+            'region_id': '$region_id',
+            'group_id': '$site_group_id',
+        },
+        label=_('Site'),
+        fetch_trigger='open'
+    )
+    role_id = DynamicModelMultipleChoiceField(
+        queryset=DeviceRole.objects.all(),
+        required=False,
+        null_option='None',
+        query_params={
+            'vm_role': "True"
+        },
+        label=_('Role'),
+        fetch_trigger='open'
+    )
+    status = forms.MultipleChoiceField(
+        choices=VirtualMachineStatusChoices,
+        required=False,
+        widget=StaticSelectMultiple()
+    )
+    platform_id = DynamicModelMultipleChoiceField(
+        queryset=Platform.objects.all(),
+        required=False,
+        null_option='None',
+        label=_('Platform'),
+        fetch_trigger='open'
+    )
+    mac_address = forms.CharField(
+        required=False,
+        label='MAC address'
+    )
+    has_primary_ip = forms.NullBooleanField(
+        required=False,
+        label='Has a primary IP',
+        widget=StaticSelect(
+            choices=BOOLEAN_WITH_BLANK_CHOICES
+        )
+    )
+    tag = TagFilterField(model)
+
+
+class VMInterfaceFilterForm(BootstrapMixin, forms.Form):
+    model = VMInterface
+    field_groups = [
+        ['q', 'tag'],
+        ['cluster_id', 'virtual_machine_id'],
+        ['enabled', 'mac_address'],
+    ]
+    q = forms.CharField(
+        required=False,
+        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
+        label=_('Search')
+    )
+    cluster_id = DynamicModelMultipleChoiceField(
+        queryset=Cluster.objects.all(),
+        required=False,
+        label=_('Cluster'),
+        fetch_trigger='open'
+    )
+    virtual_machine_id = DynamicModelMultipleChoiceField(
+        queryset=VirtualMachine.objects.all(),
+        required=False,
+        query_params={
+            'cluster_id': '$cluster_id'
+        },
+        label=_('Virtual machine'),
+        fetch_trigger='open'
+    )
+    enabled = forms.NullBooleanField(
+        required=False,
+        widget=StaticSelect(
+            choices=BOOLEAN_WITH_BLANK_CHOICES
+        )
+    )
+    mac_address = forms.CharField(
+        required=False,
+        label='MAC address'
+    )
+    tag = TagFilterField(model)

+ 324 - 0
netbox/virtualization/forms/models.py

@@ -0,0 +1,324 @@
+from django import forms
+from django.contrib.contenttypes.models import ContentType
+from django.core.exceptions import ValidationError
+
+from dcim.forms.common import InterfaceCommonForm
+from dcim.forms.models import INTERFACE_MODE_HELP_TEXT
+from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site, SiteGroup
+from extras.forms import CustomFieldModelForm
+from extras.models import Tag
+from ipam.models import IPAddress, VLAN, VLANGroup
+from tenancy.forms import TenancyForm
+from utilities.forms import (
+    BootstrapMixin, CommentField, ConfirmationForm, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
+    JSONField, SlugField, StaticSelect,
+)
+from virtualization.models import *
+
+__all__ = (
+    'ClusterAddDevicesForm',
+    'ClusterForm',
+    'ClusterGroupForm',
+    'ClusterRemoveDevicesForm',
+    'ClusterTypeForm',
+    'VirtualMachineForm',
+    'VMInterfaceForm',
+)
+
+
+class ClusterTypeForm(BootstrapMixin, CustomFieldModelForm):
+    slug = SlugField()
+
+    class Meta:
+        model = ClusterType
+        fields = [
+            'name', 'slug', 'description',
+        ]
+
+
+class ClusterGroupForm(BootstrapMixin, CustomFieldModelForm):
+    slug = SlugField()
+
+    class Meta:
+        model = ClusterGroup
+        fields = [
+            'name', 'slug', 'description',
+        ]
+
+
+class ClusterForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
+    type = DynamicModelChoiceField(
+        queryset=ClusterType.objects.all()
+    )
+    group = DynamicModelChoiceField(
+        queryset=ClusterGroup.objects.all(),
+        required=False
+    )
+    region = DynamicModelChoiceField(
+        queryset=Region.objects.all(),
+        required=False,
+        initial_params={
+            'sites': '$site'
+        }
+    )
+    site_group = DynamicModelChoiceField(
+        queryset=SiteGroup.objects.all(),
+        required=False,
+        initial_params={
+            'sites': '$site'
+        }
+    )
+    site = DynamicModelChoiceField(
+        queryset=Site.objects.all(),
+        required=False,
+        query_params={
+            'region_id': '$region',
+            'group_id': '$site_group',
+        }
+    )
+    comments = CommentField()
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
+        required=False
+    )
+
+    class Meta:
+        model = Cluster
+        fields = (
+            'name', 'type', 'group', 'tenant', 'region', 'site_group', 'site', 'comments', 'tags',
+        )
+        fieldsets = (
+            ('Cluster', ('name', 'type', 'group', 'region', 'site_group', 'site', 'tags')),
+            ('Tenancy', ('tenant_group', 'tenant')),
+        )
+
+
+class ClusterAddDevicesForm(BootstrapMixin, forms.Form):
+    region = DynamicModelChoiceField(
+        queryset=Region.objects.all(),
+        required=False,
+        null_option='None'
+    )
+    site_group = DynamicModelChoiceField(
+        queryset=SiteGroup.objects.all(),
+        required=False,
+        null_option='None'
+    )
+    site = DynamicModelChoiceField(
+        queryset=Site.objects.all(),
+        required=False,
+        query_params={
+            'region_id': '$region',
+            'group_id': '$site_group',
+        }
+    )
+    rack = DynamicModelChoiceField(
+        queryset=Rack.objects.all(),
+        required=False,
+        null_option='None',
+        query_params={
+            'site_id': '$site'
+        }
+    )
+    devices = DynamicModelMultipleChoiceField(
+        queryset=Device.objects.all(),
+        query_params={
+            'site_id': '$site',
+            'rack_id': '$rack',
+            'cluster_id': 'null',
+        }
+    )
+
+    class Meta:
+        fields = [
+            'region', 'site', 'rack', 'devices',
+        ]
+
+    def __init__(self, cluster, *args, **kwargs):
+
+        self.cluster = cluster
+
+        super().__init__(*args, **kwargs)
+
+        self.fields['devices'].choices = []
+
+    def clean(self):
+        super().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()
+    )
+
+
+class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
+    cluster_group = DynamicModelChoiceField(
+        queryset=ClusterGroup.objects.all(),
+        required=False,
+        null_option='None',
+        initial_params={
+            'clusters': '$cluster'
+        }
+    )
+    cluster = DynamicModelChoiceField(
+        queryset=Cluster.objects.all(),
+        query_params={
+            'group_id': '$cluster_group'
+        }
+    )
+    role = DynamicModelChoiceField(
+        queryset=DeviceRole.objects.all(),
+        required=False,
+        query_params={
+            "vm_role": "True"
+        }
+    )
+    platform = DynamicModelChoiceField(
+        queryset=Platform.objects.all(),
+        required=False
+    )
+    local_context_data = JSONField(
+        required=False,
+        label=''
+    )
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
+        required=False
+    )
+
+    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',
+        ]
+        fieldsets = (
+            ('Virtual Machine', ('name', 'role', 'status', 'tags')),
+            ('Cluster', ('cluster_group', 'cluster')),
+            ('Tenancy', ('tenant_group', 'tenant')),
+            ('Management', ('platform', 'primary_ip4', 'primary_ip6')),
+            ('Resources', ('vcpus', 'memory', 'disk')),
+            ('Config Context', ('local_context_data',)),
+        )
+        help_texts = {
+            'local_context_data': "Local config context data overwrites all sources contexts in the final rendered "
+                                  "config context",
+        }
+        widgets = {
+            "status": StaticSelect(),
+            'primary_ip4': StaticSelect(),
+            'primary_ip6': StaticSelect(),
+        }
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        if self.instance.pk:
+
+            # Compile list of choices for primary IPv4 and IPv6 addresses
+            for family in [4, 6]:
+                ip_choices = [(None, '---------')]
+
+                # Gather PKs of all interfaces belonging to this VM
+                interface_ids = self.instance.interfaces.values_list('pk', flat=True)
+
+                # Collect interface IPs
+                interface_ips = IPAddress.objects.filter(
+                    address__family=family,
+                    assigned_object_type=ContentType.objects.get_for_model(VMInterface),
+                    assigned_object_id__in=interface_ids
+                )
+                if interface_ips:
+                    ip_list = [(ip.id, f'{ip.address} ({ip.assigned_object})') for ip in interface_ips]
+                    ip_choices.append(('Interface IPs', ip_list))
+                # Collect NAT IPs
+                nat_ips = IPAddress.objects.prefetch_related('nat_inside').filter(
+                    address__family=family,
+                    nat_inside__assigned_object_type=ContentType.objects.get_for_model(VMInterface),
+                    nat_inside__assigned_object_id__in=interface_ids
+                )
+                if nat_ips:
+                    ip_list = [(ip.id, f'{ip.address} (NAT)') for ip in nat_ips]
+                    ip_choices.append(('NAT IPs', ip_list))
+                self.fields['primary_ip{}'.format(family)].choices = ip_choices
+
+        else:
+
+            # An object that doesn't exist yet can't have any IPs assigned to it
+            self.fields['primary_ip4'].choices = []
+            self.fields['primary_ip4'].widget.attrs['readonly'] = True
+            self.fields['primary_ip6'].choices = []
+            self.fields['primary_ip6'].widget.attrs['readonly'] = True
+
+
+class VMInterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm):
+    parent = DynamicModelChoiceField(
+        queryset=VMInterface.objects.all(),
+        required=False,
+        label='Parent interface'
+    )
+    vlan_group = DynamicModelChoiceField(
+        queryset=VLANGroup.objects.all(),
+        required=False,
+        label='VLAN group'
+    )
+    untagged_vlan = DynamicModelChoiceField(
+        queryset=VLAN.objects.all(),
+        required=False,
+        label='Untagged VLAN',
+        query_params={
+            'group_id': '$vlan_group',
+        }
+    )
+    tagged_vlans = DynamicModelMultipleChoiceField(
+        queryset=VLAN.objects.all(),
+        required=False,
+        label='Tagged VLANs',
+        query_params={
+            'group_id': '$vlan_group',
+        }
+    )
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
+        required=False
+    )
+
+    class Meta:
+        model = VMInterface
+        fields = [
+            'virtual_machine', 'name', 'enabled', 'parent', 'mac_address', 'mtu', 'description', 'mode', 'tags',
+            'untagged_vlan', 'tagged_vlans',
+        ]
+        widgets = {
+            'virtual_machine': forms.HiddenInput(),
+            'mode': StaticSelect()
+        }
+        labels = {
+            'mode': '802.1Q Mode',
+        }
+        help_texts = {
+            'mode': INTERFACE_MODE_HELP_TEXT,
+        }
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        vm_id = self.initial.get('virtual_machine') or self.data.get('virtual_machine')
+
+        # Restrict parent interface assignment by VM
+        self.fields['parent'].widget.add_query_param('virtual_machine_id', vm_id)
+
+        # Limit VLAN choices by virtual machine
+        self.fields['untagged_vlan'].widget.add_query_param('available_on_virtualmachine', vm_id)
+        self.fields['tagged_vlans'].widget.add_query_param('available_on_virtualmachine', vm_id)

+ 74 - 0
netbox/virtualization/forms/object_create.py

@@ -0,0 +1,74 @@
+from django import forms
+
+from dcim.choices import InterfaceModeChoices
+from dcim.forms.common import InterfaceCommonForm
+from extras.forms import CustomFieldsMixin
+from extras.models import Tag
+from ipam.models import VLAN
+from utilities.forms import (
+    add_blank_choice, BootstrapMixin, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField,
+    StaticSelect,
+)
+from virtualization.models import VMInterface, VirtualMachine
+
+__all__ = (
+    'VMInterfaceCreateForm',
+)
+
+
+class VMInterfaceCreateForm(BootstrapMixin, CustomFieldsMixin, InterfaceCommonForm):
+    model = VMInterface
+    virtual_machine = DynamicModelChoiceField(
+        queryset=VirtualMachine.objects.all()
+    )
+    name_pattern = ExpandableNameField(
+        label='Name'
+    )
+    enabled = forms.BooleanField(
+        required=False,
+        initial=True
+    )
+    parent = DynamicModelChoiceField(
+        queryset=VMInterface.objects.all(),
+        required=False,
+        query_params={
+            'virtual_machine_id': '$virtual_machine',
+        }
+    )
+    mac_address = forms.CharField(
+        required=False,
+        label='MAC Address'
+    )
+    description = forms.CharField(
+        max_length=200,
+        required=False
+    )
+    mode = forms.ChoiceField(
+        choices=add_blank_choice(InterfaceModeChoices),
+        required=False,
+        widget=StaticSelect(),
+    )
+    untagged_vlan = DynamicModelChoiceField(
+        queryset=VLAN.objects.all(),
+        required=False
+    )
+    tagged_vlans = DynamicModelMultipleChoiceField(
+        queryset=VLAN.objects.all(),
+        required=False
+    )
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
+        required=False
+    )
+    field_order = (
+        'virtual_machine', 'name_pattern', 'enabled', 'parent', 'mtu', 'mac_address', 'description', 'mode',
+        'untagged_vlan', 'tagged_vlans', 'tags'
+    )
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        vm_id = self.initial.get('virtual_machine') or self.data.get('virtual_machine')
+
+        # Limit VLAN choices by virtual machine
+        self.fields['untagged_vlan'].widget.add_query_param('available_on_virtualmachine', vm_id)
+        self.fields['tagged_vlans'].widget.add_query_param('available_on_virtualmachine', vm_id)