Просмотр исходного кода

Merge pull request #7382 from netbox-community/refactor-forms

Refactor forms
Jeremy Stretch 4 лет назад
Родитель
Сommit
16f5e233d0
51 измененных файлов с 10481 добавлено и 10078 удалено
  1. 0 513
      netbox/circuits/forms.py
  2. 4 0
      netbox/circuits/forms/__init__.py
  3. 135 0
      netbox/circuits/forms/bulk_edit.py
  4. 77 0
      netbox/circuits/forms/bulk_import.py
  5. 159 0
      netbox/circuits/forms/filtersets.py
  6. 168 0
      netbox/circuits/forms/models.py
  7. 0 5533
      netbox/dcim/forms.py
  8. 10 0
      netbox/dcim/forms/__init__.py
  9. 111 0
      netbox/dcim/forms/bulk_create.py
  10. 1090 0
      netbox/dcim/forms/bulk_edit.py
  11. 976 0
      netbox/dcim/forms/bulk_import.py
  12. 49 0
      netbox/dcim/forms/common.py
  13. 289 0
      netbox/dcim/forms/connections.py
  14. 25 0
      netbox/dcim/forms/fields.py
  15. 1143 0
      netbox/dcim/forms/filtersets.py
  16. 21 0
      netbox/dcim/forms/formsets.py
  17. 1232 0
      netbox/dcim/forms/models.py
  18. 614 0
      netbox/dcim/forms/object_create.py
  19. 148 0
      netbox/dcim/forms/object_import.py
  20. 1 0
      netbox/dcim/tests/test_forms.py
  21. 1 2
      netbox/dcim/views.py
  22. 0 988
      netbox/extras/forms.py
  23. 6 0
      netbox/extras/forms/__init__.py
  24. 199 0
      netbox/extras/forms/bulk_edit.py
  25. 91 0
      netbox/extras/forms/bulk_import.py
  26. 123 0
      netbox/extras/forms/customfields.py
  27. 364 0
      netbox/extras/forms/filtersets.py
  28. 223 0
      netbox/extras/forms/models.py
  29. 30 0
      netbox/extras/forms/scripts.py
  30. 0 1881
      netbox/ipam/forms.py
  31. 5 0
      netbox/ipam/forms/__init__.py
  32. 13 0
      netbox/ipam/forms/bulk_create.py
  33. 378 0
      netbox/ipam/forms/bulk_edit.py
  34. 362 0
      netbox/ipam/forms/bulk_import.py
  35. 486 0
      netbox/ipam/forms/filtersets.py
  36. 691 0
      netbox/ipam/forms/models.py
  37. 0 196
      netbox/tenancy/forms.py
  38. 5 0
      netbox/tenancy/forms/__init__.py
  39. 44 0
      netbox/tenancy/forms/bulk_edit.py
  40. 36 0
      netbox/tenancy/forms/bulk_import.py
  41. 42 0
      netbox/tenancy/forms/filtersets.py
  42. 48 0
      netbox/tenancy/forms/forms.py
  43. 47 0
      netbox/tenancy/forms/models.py
  44. 0 965
      netbox/virtualization/forms.py
  45. 6 0
      netbox/virtualization/forms/__init__.py
  46. 30 0
      netbox/virtualization/forms/bulk_create.py
  47. 239 0
      netbox/virtualization/forms/bulk_edit.py
  48. 125 0
      netbox/virtualization/forms/bulk_import.py
  49. 237 0
      netbox/virtualization/forms/filtersets.py
  50. 324 0
      netbox/virtualization/forms/models.py
  51. 74 0
      netbox/virtualization/forms/object_create.py

+ 0 - 513
netbox/circuits/forms.py

@@ -1,513 +0,0 @@
-from django import forms
-from django.utils.translation import gettext as _
-
-from dcim.models import Region, Site, SiteGroup
-from extras.forms import (
-    AddRemoveTagsForm, CustomFieldModelBulkEditForm, CustomFieldModelFilterForm, CustomFieldModelForm, CustomFieldModelCSVForm,
-)
-from extras.models import Tag
-from tenancy.forms import TenancyFilterForm, TenancyForm
-from tenancy.models import Tenant
-from utilities.forms import (
-    add_blank_choice, BootstrapMixin, CommentField, CSVChoiceField, CSVModelChoiceField, DatePicker,
-    DynamicModelChoiceField, DynamicModelMultipleChoiceField, SelectSpeedWidget, SmallTextarea, SlugField,
-    StaticSelect, StaticSelectMultiple, TagFilterField,
-)
-from .choices import CircuitStatusChoices
-from .models import *
-
-
-#
-# Providers
-#
-
-class ProviderForm(BootstrapMixin, CustomFieldModelForm):
-    slug = SlugField()
-    comments = CommentField()
-    tags = DynamicModelMultipleChoiceField(
-        queryset=Tag.objects.all(),
-        required=False
-    )
-
-    class Meta:
-        model = Provider
-        fields = [
-            'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', 'tags',
-        ]
-        fieldsets = (
-            ('Provider', ('name', 'slug', 'asn', 'tags')),
-            ('Support Info', ('account', 'portal_url', 'noc_contact', 'admin_contact')),
-        )
-        widgets = {
-            'noc_contact': SmallTextarea(
-                attrs={'rows': 5}
-            ),
-            'admin_contact': SmallTextarea(
-                attrs={'rows': 5}
-            ),
-        }
-        help_texts = {
-            'name': "Full name of the provider",
-            'asn': "BGP autonomous system number (if applicable)",
-            'portal_url': "URL of the provider's customer support portal",
-            'noc_contact': "NOC email address and phone number",
-            'admin_contact': "Administrative contact email address and phone number",
-        }
-
-
-class ProviderCSVForm(CustomFieldModelCSVForm):
-    slug = SlugField()
-
-    class Meta:
-        model = Provider
-        fields = (
-            'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments',
-        )
-
-
-class ProviderBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=Provider.objects.all(),
-        widget=forms.MultipleHiddenInput
-    )
-    asn = forms.IntegerField(
-        required=False,
-        label='ASN'
-    )
-    account = forms.CharField(
-        max_length=30,
-        required=False,
-        label='Account number'
-    )
-    portal_url = forms.URLField(
-        required=False,
-        label='Portal'
-    )
-    noc_contact = forms.CharField(
-        required=False,
-        widget=SmallTextarea,
-        label='NOC contact'
-    )
-    admin_contact = forms.CharField(
-        required=False,
-        widget=SmallTextarea,
-        label='Admin contact'
-    )
-    comments = CommentField(
-        widget=SmallTextarea,
-        label='Comments'
-    )
-
-    class Meta:
-        nullable_fields = [
-            'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments',
-        ]
-
-
-class ProviderFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
-    model = Provider
-    field_groups = [
-        ['q', 'tag'],
-        ['region_id', 'site_group_id', 'site_id'],
-        ['asn'],
-    ]
-    q = forms.CharField(
-        required=False,
-        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
-        label=_('Search')
-    )
-    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,
-        query_params={
-            'region_id': '$region_id',
-            'site_group_id': '$site_group_id',
-        },
-        label=_('Site'),
-        fetch_trigger='open'
-    )
-    asn = forms.IntegerField(
-        required=False,
-        label=_('ASN')
-    )
-    tag = TagFilterField(model)
-
-
-#
-# Provider networks
-#
-
-class ProviderNetworkForm(BootstrapMixin, CustomFieldModelForm):
-    provider = DynamicModelChoiceField(
-        queryset=Provider.objects.all()
-    )
-    comments = CommentField()
-    tags = DynamicModelMultipleChoiceField(
-        queryset=Tag.objects.all(),
-        required=False
-    )
-
-    class Meta:
-        model = ProviderNetwork
-        fields = [
-            'provider', 'name', 'description', 'comments', 'tags',
-        ]
-        fieldsets = (
-            ('Provider Network', ('provider', 'name', 'description', 'tags')),
-        )
-
-
-class ProviderNetworkCSVForm(CustomFieldModelCSVForm):
-    provider = CSVModelChoiceField(
-        queryset=Provider.objects.all(),
-        to_field_name='name',
-        help_text='Assigned provider'
-    )
-
-    class Meta:
-        model = ProviderNetwork
-        fields = [
-            'provider', 'name', 'description', 'comments',
-        ]
-
-
-class ProviderNetworkBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=ProviderNetwork.objects.all(),
-        widget=forms.MultipleHiddenInput
-    )
-    provider = DynamicModelChoiceField(
-        queryset=Provider.objects.all(),
-        required=False
-    )
-    description = forms.CharField(
-        max_length=100,
-        required=False
-    )
-    comments = CommentField(
-        widget=SmallTextarea,
-        label='Comments'
-    )
-
-    class Meta:
-        nullable_fields = [
-            'description', 'comments',
-        ]
-
-
-class ProviderNetworkFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
-    model = ProviderNetwork
-    field_groups = (
-        ('q', 'tag'),
-        ('provider_id',),
-    )
-    q = forms.CharField(
-        required=False,
-        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
-        label=_('Search')
-    )
-    provider_id = DynamicModelMultipleChoiceField(
-        queryset=Provider.objects.all(),
-        required=False,
-        label=_('Provider'),
-        fetch_trigger='open'
-    )
-    tag = TagFilterField(model)
-
-
-#
-# Circuit types
-#
-
-class CircuitTypeForm(BootstrapMixin, CustomFieldModelForm):
-    slug = SlugField()
-
-    class Meta:
-        model = CircuitType
-        fields = [
-            'name', 'slug', 'description',
-        ]
-
-
-class CircuitTypeBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=CircuitType.objects.all(),
-        widget=forms.MultipleHiddenInput
-    )
-    description = forms.CharField(
-        max_length=200,
-        required=False
-    )
-
-    class Meta:
-        nullable_fields = ['description']
-
-
-class CircuitTypeCSVForm(CustomFieldModelCSVForm):
-    slug = SlugField()
-
-    class Meta:
-        model = CircuitType
-        fields = ('name', 'slug', 'description')
-        help_texts = {
-            'name': 'Name of circuit type',
-        }
-
-
-class CircuitTypeFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
-    model = CircuitType
-    field_groups = [
-        ['q'],
-    ]
-    q = forms.CharField(
-        required=False,
-        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
-        label=_('Search')
-    )
-
-
-#
-# Circuits
-#
-
-class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
-    provider = DynamicModelChoiceField(
-        queryset=Provider.objects.all()
-    )
-    type = DynamicModelChoiceField(
-        queryset=CircuitType.objects.all()
-    )
-    comments = CommentField()
-    tags = DynamicModelMultipleChoiceField(
-        queryset=Tag.objects.all(),
-        required=False
-    )
-
-    class Meta:
-        model = Circuit
-        fields = [
-            'cid', 'type', 'provider', 'status', 'install_date', 'commit_rate', 'description', 'tenant_group', 'tenant',
-            'comments', 'tags',
-        ]
-        fieldsets = (
-            ('Circuit', ('provider', 'cid', 'type', 'status', 'install_date', 'commit_rate', 'description', 'tags')),
-            ('Tenancy', ('tenant_group', 'tenant')),
-        )
-        help_texts = {
-            'cid': "Unique circuit ID",
-            'commit_rate': "Committed rate",
-        }
-        widgets = {
-            'status': StaticSelect(),
-            'install_date': DatePicker(),
-            'commit_rate': SelectSpeedWidget(),
-        }
-
-
-class CircuitCSVForm(CustomFieldModelCSVForm):
-    provider = CSVModelChoiceField(
-        queryset=Provider.objects.all(),
-        to_field_name='name',
-        help_text='Assigned provider'
-    )
-    type = CSVModelChoiceField(
-        queryset=CircuitType.objects.all(),
-        to_field_name='name',
-        help_text='Type of circuit'
-    )
-    status = CSVChoiceField(
-        choices=CircuitStatusChoices,
-        required=False,
-        help_text='Operational status'
-    )
-    tenant = CSVModelChoiceField(
-        queryset=Tenant.objects.all(),
-        required=False,
-        to_field_name='name',
-        help_text='Assigned tenant'
-    )
-
-    class Meta:
-        model = Circuit
-        fields = [
-            'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description', 'comments',
-        ]
-
-
-class CircuitBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=Circuit.objects.all(),
-        widget=forms.MultipleHiddenInput
-    )
-    type = DynamicModelChoiceField(
-        queryset=CircuitType.objects.all(),
-        required=False
-    )
-    provider = DynamicModelChoiceField(
-        queryset=Provider.objects.all(),
-        required=False
-    )
-    status = forms.ChoiceField(
-        choices=add_blank_choice(CircuitStatusChoices),
-        required=False,
-        initial='',
-        widget=StaticSelect()
-    )
-    tenant = DynamicModelChoiceField(
-        queryset=Tenant.objects.all(),
-        required=False
-    )
-    commit_rate = forms.IntegerField(
-        required=False,
-        label='Commit rate (Kbps)'
-    )
-    description = forms.CharField(
-        max_length=100,
-        required=False
-    )
-    comments = CommentField(
-        widget=SmallTextarea,
-        label='Comments'
-    )
-
-    class Meta:
-        nullable_fields = [
-            'tenant', 'commit_rate', 'description', 'comments',
-        ]
-
-
-class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
-    model = Circuit
-    field_groups = [
-        ['q', 'tag'],
-        ['provider_id', 'provider_network_id'],
-        ['type_id', 'status', 'commit_rate'],
-        ['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=CircuitType.objects.all(),
-        required=False,
-        label=_('Type'),
-        fetch_trigger='open'
-    )
-    provider_id = DynamicModelMultipleChoiceField(
-        queryset=Provider.objects.all(),
-        required=False,
-        label=_('Provider'),
-        fetch_trigger='open'
-    )
-    provider_network_id = DynamicModelMultipleChoiceField(
-        queryset=ProviderNetwork.objects.all(),
-        required=False,
-        query_params={
-            'provider_id': '$provider_id'
-        },
-        label=_('Provider network'),
-        fetch_trigger='open'
-    )
-    status = forms.MultipleChoiceField(
-        choices=CircuitStatusChoices,
-        required=False,
-        widget=StaticSelectMultiple()
-    )
-    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,
-        query_params={
-            'region_id': '$region_id',
-            'site_group_id': '$site_group_id',
-        },
-        label=_('Site'),
-        fetch_trigger='open'
-    )
-    commit_rate = forms.IntegerField(
-        required=False,
-        min_value=0,
-        label=_('Commit rate (Kbps)')
-    )
-    tag = TagFilterField(model)
-
-
-#
-# Circuit terminations
-#
-
-class CircuitTerminationForm(BootstrapMixin, forms.ModelForm):
-    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(),
-        query_params={
-            'region_id': '$region',
-            'group_id': '$site_group',
-        },
-        required=False
-    )
-    provider_network = DynamicModelChoiceField(
-        queryset=ProviderNetwork.objects.all(),
-        required=False
-    )
-
-    class Meta:
-        model = CircuitTermination
-        fields = [
-            'term_side', 'region', 'site_group', 'site', 'provider_network', 'mark_connected', 'port_speed',
-            'upstream_speed', 'xconnect_id', 'pp_info', 'description',
-        ]
-        help_texts = {
-            'port_speed': "Physical circuit speed",
-            'xconnect_id': "ID of the local cross-connect",
-            'pp_info': "Patch panel ID and port number(s)"
-        }
-        widgets = {
-            'term_side': forms.HiddenInput(),
-            'port_speed': SelectSpeedWidget(),
-            'upstream_speed': SelectSpeedWidget(),
-        }
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-
-        self.fields['provider_network'].widget.add_query_param('provider_id', self.instance.circuit.provider_id)

+ 4 - 0
netbox/circuits/forms/__init__.py

@@ -0,0 +1,4 @@
+from .bulk_edit import *
+from .bulk_import import *
+from .filtersets import *
+from .models import *

+ 135 - 0
netbox/circuits/forms/bulk_edit.py

@@ -0,0 +1,135 @@
+from django import forms
+
+from circuits.choices import CircuitStatusChoices
+from circuits.models import *
+from extras.forms import AddRemoveTagsForm, CustomFieldModelBulkEditForm
+from tenancy.models import Tenant
+from utilities.forms import (
+    add_blank_choice, BootstrapMixin, CommentField, DynamicModelChoiceField, SmallTextarea, StaticSelect,
+)
+
+__all__ = (
+    'CircuitBulkEditForm',
+    'CircuitTypeBulkEditForm',
+    'ProviderBulkEditForm',
+    'ProviderNetworkBulkEditForm',
+)
+
+
+class ProviderBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=Provider.objects.all(),
+        widget=forms.MultipleHiddenInput
+    )
+    asn = forms.IntegerField(
+        required=False,
+        label='ASN'
+    )
+    account = forms.CharField(
+        max_length=30,
+        required=False,
+        label='Account number'
+    )
+    portal_url = forms.URLField(
+        required=False,
+        label='Portal'
+    )
+    noc_contact = forms.CharField(
+        required=False,
+        widget=SmallTextarea,
+        label='NOC contact'
+    )
+    admin_contact = forms.CharField(
+        required=False,
+        widget=SmallTextarea,
+        label='Admin contact'
+    )
+    comments = CommentField(
+        widget=SmallTextarea,
+        label='Comments'
+    )
+
+    class Meta:
+        nullable_fields = [
+            'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments',
+        ]
+
+
+class ProviderNetworkBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=ProviderNetwork.objects.all(),
+        widget=forms.MultipleHiddenInput
+    )
+    provider = DynamicModelChoiceField(
+        queryset=Provider.objects.all(),
+        required=False
+    )
+    description = forms.CharField(
+        max_length=100,
+        required=False
+    )
+    comments = CommentField(
+        widget=SmallTextarea,
+        label='Comments'
+    )
+
+    class Meta:
+        nullable_fields = [
+            'description', 'comments',
+        ]
+
+
+class CircuitTypeBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=CircuitType.objects.all(),
+        widget=forms.MultipleHiddenInput
+    )
+    description = forms.CharField(
+        max_length=200,
+        required=False
+    )
+
+    class Meta:
+        nullable_fields = ['description']
+
+
+class CircuitBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=Circuit.objects.all(),
+        widget=forms.MultipleHiddenInput
+    )
+    type = DynamicModelChoiceField(
+        queryset=CircuitType.objects.all(),
+        required=False
+    )
+    provider = DynamicModelChoiceField(
+        queryset=Provider.objects.all(),
+        required=False
+    )
+    status = forms.ChoiceField(
+        choices=add_blank_choice(CircuitStatusChoices),
+        required=False,
+        initial='',
+        widget=StaticSelect()
+    )
+    tenant = DynamicModelChoiceField(
+        queryset=Tenant.objects.all(),
+        required=False
+    )
+    commit_rate = forms.IntegerField(
+        required=False,
+        label='Commit rate (Kbps)'
+    )
+    description = forms.CharField(
+        max_length=100,
+        required=False
+    )
+    comments = CommentField(
+        widget=SmallTextarea,
+        label='Comments'
+    )
+
+    class Meta:
+        nullable_fields = [
+            'tenant', 'commit_rate', 'description', 'comments',
+        ]

+ 77 - 0
netbox/circuits/forms/bulk_import.py

@@ -0,0 +1,77 @@
+from circuits.choices import CircuitStatusChoices
+from circuits.models import *
+from extras.forms import CustomFieldModelCSVForm
+from tenancy.models import Tenant
+from utilities.forms import CSVChoiceField, CSVModelChoiceField, SlugField
+
+__all__ = (
+    'CircuitCSVForm',
+    'CircuitTypeCSVForm',
+    'ProviderCSVForm',
+    'ProviderNetworkCSVForm',
+)
+
+
+class ProviderCSVForm(CustomFieldModelCSVForm):
+    slug = SlugField()
+
+    class Meta:
+        model = Provider
+        fields = (
+            'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments',
+        )
+
+
+class ProviderNetworkCSVForm(CustomFieldModelCSVForm):
+    provider = CSVModelChoiceField(
+        queryset=Provider.objects.all(),
+        to_field_name='name',
+        help_text='Assigned provider'
+    )
+
+    class Meta:
+        model = ProviderNetwork
+        fields = [
+            'provider', 'name', 'description', 'comments',
+        ]
+
+
+class CircuitTypeCSVForm(CustomFieldModelCSVForm):
+    slug = SlugField()
+
+    class Meta:
+        model = CircuitType
+        fields = ('name', 'slug', 'description')
+        help_texts = {
+            'name': 'Name of circuit type',
+        }
+
+
+class CircuitCSVForm(CustomFieldModelCSVForm):
+    provider = CSVModelChoiceField(
+        queryset=Provider.objects.all(),
+        to_field_name='name',
+        help_text='Assigned provider'
+    )
+    type = CSVModelChoiceField(
+        queryset=CircuitType.objects.all(),
+        to_field_name='name',
+        help_text='Type of circuit'
+    )
+    status = CSVChoiceField(
+        choices=CircuitStatusChoices,
+        required=False,
+        help_text='Operational status'
+    )
+    tenant = CSVModelChoiceField(
+        queryset=Tenant.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text='Assigned tenant'
+    )
+
+    class Meta:
+        model = Circuit
+        fields = [
+            'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description', 'comments',
+        ]

+ 159 - 0
netbox/circuits/forms/filtersets.py

@@ -0,0 +1,159 @@
+from django import forms
+from django.utils.translation import gettext as _
+
+from circuits.choices import CircuitStatusChoices
+from circuits.models import *
+from dcim.models import Region, Site, SiteGroup
+from extras.forms import CustomFieldModelFilterForm
+from tenancy.forms import TenancyFilterForm
+from utilities.forms import BootstrapMixin, DynamicModelMultipleChoiceField, StaticSelectMultiple, TagFilterField
+
+__all__ = (
+    'CircuitFilterForm',
+    'CircuitTypeFilterForm',
+    'ProviderFilterForm',
+    'ProviderNetworkFilterForm',
+)
+
+
+class ProviderFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
+    model = Provider
+    field_groups = [
+        ['q', 'tag'],
+        ['region_id', 'site_group_id', 'site_id'],
+        ['asn'],
+    ]
+    q = forms.CharField(
+        required=False,
+        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
+        label=_('Search')
+    )
+    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,
+        query_params={
+            'region_id': '$region_id',
+            'site_group_id': '$site_group_id',
+        },
+        label=_('Site'),
+        fetch_trigger='open'
+    )
+    asn = forms.IntegerField(
+        required=False,
+        label=_('ASN')
+    )
+    tag = TagFilterField(model)
+
+
+class ProviderNetworkFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
+    model = ProviderNetwork
+    field_groups = (
+        ('q', 'tag'),
+        ('provider_id',),
+    )
+    q = forms.CharField(
+        required=False,
+        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
+        label=_('Search')
+    )
+    provider_id = DynamicModelMultipleChoiceField(
+        queryset=Provider.objects.all(),
+        required=False,
+        label=_('Provider'),
+        fetch_trigger='open'
+    )
+    tag = TagFilterField(model)
+
+
+class CircuitTypeFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
+    model = CircuitType
+    field_groups = [
+        ['q'],
+    ]
+    q = forms.CharField(
+        required=False,
+        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
+        label=_('Search')
+    )
+
+
+class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
+    model = Circuit
+    field_groups = [
+        ['q', 'tag'],
+        ['provider_id', 'provider_network_id'],
+        ['type_id', 'status', 'commit_rate'],
+        ['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=CircuitType.objects.all(),
+        required=False,
+        label=_('Type'),
+        fetch_trigger='open'
+    )
+    provider_id = DynamicModelMultipleChoiceField(
+        queryset=Provider.objects.all(),
+        required=False,
+        label=_('Provider'),
+        fetch_trigger='open'
+    )
+    provider_network_id = DynamicModelMultipleChoiceField(
+        queryset=ProviderNetwork.objects.all(),
+        required=False,
+        query_params={
+            'provider_id': '$provider_id'
+        },
+        label=_('Provider network'),
+        fetch_trigger='open'
+    )
+    status = forms.MultipleChoiceField(
+        choices=CircuitStatusChoices,
+        required=False,
+        widget=StaticSelectMultiple()
+    )
+    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,
+        query_params={
+            'region_id': '$region_id',
+            'site_group_id': '$site_group_id',
+        },
+        label=_('Site'),
+        fetch_trigger='open'
+    )
+    commit_rate = forms.IntegerField(
+        required=False,
+        min_value=0,
+        label=_('Commit rate (Kbps)')
+    )
+    tag = TagFilterField(model)

+ 168 - 0
netbox/circuits/forms/models.py

@@ -0,0 +1,168 @@
+from django import forms
+
+from circuits.models import *
+from dcim.models import Region, Site, SiteGroup
+from extras.forms import CustomFieldModelForm
+from extras.models import Tag
+from tenancy.forms import TenancyForm
+from utilities.forms import (
+    BootstrapMixin, CommentField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
+    SelectSpeedWidget, SmallTextarea, SlugField, StaticSelect,
+)
+
+__all__ = (
+    'CircuitForm',
+    'CircuitTerminationForm',
+    'CircuitTypeForm',
+    'ProviderForm',
+    'ProviderNetworkForm',
+)
+
+
+class ProviderForm(BootstrapMixin, CustomFieldModelForm):
+    slug = SlugField()
+    comments = CommentField()
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
+        required=False
+    )
+
+    class Meta:
+        model = Provider
+        fields = [
+            'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', 'tags',
+        ]
+        fieldsets = (
+            ('Provider', ('name', 'slug', 'asn', 'tags')),
+            ('Support Info', ('account', 'portal_url', 'noc_contact', 'admin_contact')),
+        )
+        widgets = {
+            'noc_contact': SmallTextarea(
+                attrs={'rows': 5}
+            ),
+            'admin_contact': SmallTextarea(
+                attrs={'rows': 5}
+            ),
+        }
+        help_texts = {
+            'name': "Full name of the provider",
+            'asn': "BGP autonomous system number (if applicable)",
+            'portal_url': "URL of the provider's customer support portal",
+            'noc_contact': "NOC email address and phone number",
+            'admin_contact': "Administrative contact email address and phone number",
+        }
+
+
+class ProviderNetworkForm(BootstrapMixin, CustomFieldModelForm):
+    provider = DynamicModelChoiceField(
+        queryset=Provider.objects.all()
+    )
+    comments = CommentField()
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
+        required=False
+    )
+
+    class Meta:
+        model = ProviderNetwork
+        fields = [
+            'provider', 'name', 'description', 'comments', 'tags',
+        ]
+        fieldsets = (
+            ('Provider Network', ('provider', 'name', 'description', 'tags')),
+        )
+
+
+class CircuitTypeForm(BootstrapMixin, CustomFieldModelForm):
+    slug = SlugField()
+
+    class Meta:
+        model = CircuitType
+        fields = [
+            'name', 'slug', 'description',
+        ]
+
+
+class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
+    provider = DynamicModelChoiceField(
+        queryset=Provider.objects.all()
+    )
+    type = DynamicModelChoiceField(
+        queryset=CircuitType.objects.all()
+    )
+    comments = CommentField()
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
+        required=False
+    )
+
+    class Meta:
+        model = Circuit
+        fields = [
+            'cid', 'type', 'provider', 'status', 'install_date', 'commit_rate', 'description', 'tenant_group', 'tenant',
+            'comments', 'tags',
+        ]
+        fieldsets = (
+            ('Circuit', ('provider', 'cid', 'type', 'status', 'install_date', 'commit_rate', 'description', 'tags')),
+            ('Tenancy', ('tenant_group', 'tenant')),
+        )
+        help_texts = {
+            'cid': "Unique circuit ID",
+            'commit_rate': "Committed rate",
+        }
+        widgets = {
+            'status': StaticSelect(),
+            'install_date': DatePicker(),
+            'commit_rate': SelectSpeedWidget(),
+        }
+
+
+class CircuitTerminationForm(BootstrapMixin, forms.ModelForm):
+    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(),
+        query_params={
+            'region_id': '$region',
+            'group_id': '$site_group',
+        },
+        required=False
+    )
+    provider_network = DynamicModelChoiceField(
+        queryset=ProviderNetwork.objects.all(),
+        required=False
+    )
+
+    class Meta:
+        model = CircuitTermination
+        fields = [
+            'term_side', 'region', 'site_group', 'site', 'provider_network', 'mark_connected', 'port_speed',
+            'upstream_speed', 'xconnect_id', 'pp_info', 'description',
+        ]
+        help_texts = {
+            'port_speed': "Physical circuit speed",
+            'xconnect_id': "ID of the local cross-connect",
+            'pp_info': "Patch panel ID and port number(s)"
+        }
+        widgets = {
+            'term_side': forms.HiddenInput(),
+            'port_speed': SelectSpeedWidget(),
+            'upstream_speed': SelectSpeedWidget(),
+        }
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        self.fields['provider_network'].widget.add_query_param('provider_id', self.instance.circuit.provider_id)

+ 0 - 5533
netbox/dcim/forms.py

@@ -1,5533 +0,0 @@
-import re
-
-from django import forms
-from django.contrib.auth.models import User
-from django.contrib.contenttypes.models import ContentType
-from django.contrib.postgres.forms.array import SimpleArrayField
-from django.core.exceptions import ObjectDoesNotExist
-from django.utils.safestring import mark_safe
-from django.utils.translation import gettext as _
-from netaddr import EUI
-from netaddr.core import AddrFormatError
-from timezone_field import TimeZoneFormField
-
-from circuits.models import Circuit, CircuitTermination, Provider
-from extras.forms import (
-    AddRemoveTagsForm, CustomFieldModelBulkEditForm, CustomFieldModelCSVForm, CustomFieldModelFilterForm, CustomFieldModelForm,
-    CustomFieldsMixin, LocalConfigContextFilterForm,
-)
-from extras.models import Tag
-from ipam.constants import BGP_ASN_MAX, BGP_ASN_MIN
-from ipam.models import IPAddress, VLAN, VLANGroup
-from tenancy.forms import TenancyFilterForm, TenancyForm
-from tenancy.models import Tenant
-from utilities.forms import (
-    APISelect, APISelectMultiple, add_blank_choice, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
-    ClearableFileInput, ColorField, CommentField, CSVChoiceField, CSVContentTypeField, CSVModelChoiceField,
-    CSVTypedChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model,
-    JSONField, NumericArrayField, SelectWithPK, SmallTextarea, SlugField, StaticSelect, StaticSelectMultiple,
-    TagFilterField, BOOLEAN_WITH_BLANK_CHOICES,
-)
-from virtualization.models import Cluster, ClusterGroup
-from .choices import *
-from .constants import *
-from .models import *
-
-DEVICE_BY_PK_RE = r'{\d+\}'
-
-INTERFACE_MODE_HELP_TEXT = """
-Access: One untagged VLAN<br />
-Tagged: One untagged VLAN and/or one or more tagged VLANs<br />
-Tagged (All): Implies all VLANs are available (w/optional untagged VLAN)
-"""
-
-
-def get_device_by_name_or_pk(name):
-    """
-    Attempt to retrieve a device by either its name or primary key ('{pk}').
-    """
-    if re.match(DEVICE_BY_PK_RE, name):
-        pk = name.strip('{}')
-        device = Device.objects.get(pk=pk)
-    else:
-        device = Device.objects.get(name=name)
-    return device
-
-
-class DeviceComponentFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
-    field_order = [
-        'q', 'name', 'label', 'region_id', 'site_group_id', 'site_id',
-    ]
-    q = forms.CharField(
-        required=False,
-        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
-        label=_('Search')
-    )
-    name = forms.CharField(
-        required=False
-    )
-    label = forms.CharField(
-        required=False
-    )
-    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,
-        query_params={
-            'region_id': '$region_id',
-            'group_id': '$site_group_id',
-        },
-        label=_('Site'),
-        fetch_trigger='open'
-    )
-    location_id = DynamicModelMultipleChoiceField(
-        queryset=Location.objects.all(),
-        required=False,
-        query_params={
-            'site_id': '$site_id',
-        },
-        label=_('Location'),
-        fetch_trigger='open'
-    )
-    device_id = DynamicModelMultipleChoiceField(
-        queryset=Device.objects.all(),
-        required=False,
-        query_params={
-            'site_id': '$site_id',
-            'location_id': '$location_id',
-        },
-        label=_('Device'),
-        fetch_trigger='open'
-    )
-
-
-class InterfaceCommonForm(forms.Form):
-    mac_address = forms.CharField(
-        empty_value=None,
-        required=False,
-        label='MAC address'
-    )
-    mtu = forms.IntegerField(
-        required=False,
-        min_value=INTERFACE_MTU_MIN,
-        max_value=INTERFACE_MTU_MAX,
-        label='MTU'
-    )
-
-    def clean(self):
-        super().clean()
-
-        parent_field = 'device' if 'device' in self.cleaned_data else 'virtual_machine'
-        tagged_vlans = self.cleaned_data.get('tagged_vlans')
-
-        # Untagged interfaces cannot be assigned tagged VLANs
-        if self.cleaned_data['mode'] == InterfaceModeChoices.MODE_ACCESS and tagged_vlans:
-            raise forms.ValidationError({
-                'mode': "An access interface cannot have tagged VLANs assigned."
-            })
-
-        # Remove all tagged VLAN assignments from "tagged all" interfaces
-        elif self.cleaned_data['mode'] == InterfaceModeChoices.MODE_TAGGED_ALL:
-            self.cleaned_data['tagged_vlans'] = []
-
-        # Validate tagged VLANs; must be a global VLAN or in the same site
-        elif self.cleaned_data['mode'] == InterfaceModeChoices.MODE_TAGGED and tagged_vlans:
-            valid_sites = [None, self.cleaned_data[parent_field].site]
-            invalid_vlans = [str(v) for v in tagged_vlans if v.site not in valid_sites]
-
-            if invalid_vlans:
-                raise forms.ValidationError({
-                    'tagged_vlans': f"The tagged VLANs ({', '.join(invalid_vlans)}) must belong to the same site as "
-                                    f"the interface's parent device/VM, or they must be global"
-                })
-
-
-class ComponentForm(forms.Form):
-    """
-    Subclass this form when facilitating the creation of one or more device component or component templates based on
-    a name pattern.
-    """
-    name_pattern = ExpandableNameField(
-        label='Name'
-    )
-    label_pattern = ExpandableNameField(
-        label='Label',
-        required=False,
-        help_text='Alphanumeric ranges are supported. (Must match the number of names being created.)'
-    )
-
-    def clean(self):
-        super().clean()
-
-        # Validate that the number of components being created from both the name_pattern and label_pattern are equal
-        if self.cleaned_data['label_pattern']:
-            name_pattern_count = len(self.cleaned_data['name_pattern'])
-            label_pattern_count = len(self.cleaned_data['label_pattern'])
-            if name_pattern_count != label_pattern_count:
-                raise forms.ValidationError({
-                    'label_pattern': f'The provided name pattern will create {name_pattern_count} components, however '
-                                     f'{label_pattern_count} labels will be generated. These counts must match.'
-                }, code='label_pattern_mismatch')
-
-
-#
-# Fields
-#
-
-class MACAddressField(forms.Field):
-    widget = forms.CharField
-    default_error_messages = {
-        'invalid': 'MAC address must be in EUI-48 format',
-    }
-
-    def to_python(self, value):
-        value = super().to_python(value)
-
-        # Validate MAC address format
-        try:
-            value = EUI(value.strip())
-        except AddrFormatError:
-            raise forms.ValidationError(self.error_messages['invalid'], code='invalid')
-
-        return value
-
-
-#
-# Regions
-#
-
-class RegionForm(BootstrapMixin, CustomFieldModelForm):
-    parent = DynamicModelChoiceField(
-        queryset=Region.objects.all(),
-        required=False
-    )
-    slug = SlugField()
-
-    class Meta:
-        model = Region
-        fields = (
-            'parent', 'name', 'slug', 'description',
-        )
-
-
-class RegionCSVForm(CustomFieldModelCSVForm):
-    parent = CSVModelChoiceField(
-        queryset=Region.objects.all(),
-        required=False,
-        to_field_name='name',
-        help_text='Name of parent region'
-    )
-
-    class Meta:
-        model = Region
-        fields = ('name', 'slug', 'parent', 'description')
-
-
-class RegionBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=Region.objects.all(),
-        widget=forms.MultipleHiddenInput
-    )
-    parent = DynamicModelChoiceField(
-        queryset=Region.objects.all(),
-        required=False
-    )
-    description = forms.CharField(
-        max_length=200,
-        required=False
-    )
-
-    class Meta:
-        nullable_fields = ['parent', 'description']
-
-
-class RegionFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
-    model = Region
-    field_groups = [
-        ['q'],
-        ['parent_id'],
-    ]
-    q = forms.CharField(
-        required=False,
-        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
-        label=_('Search')
-    )
-    parent_id = DynamicModelMultipleChoiceField(
-        queryset=Region.objects.all(),
-        required=False,
-        label=_('Parent region'),
-        fetch_trigger='open'
-    )
-
-
-#
-# Site groups
-#
-
-class SiteGroupForm(BootstrapMixin, CustomFieldModelForm):
-    parent = DynamicModelChoiceField(
-        queryset=SiteGroup.objects.all(),
-        required=False
-    )
-    slug = SlugField()
-
-    class Meta:
-        model = SiteGroup
-        fields = (
-            'parent', 'name', 'slug', 'description',
-        )
-
-
-class SiteGroupCSVForm(CustomFieldModelCSVForm):
-    parent = CSVModelChoiceField(
-        queryset=SiteGroup.objects.all(),
-        required=False,
-        to_field_name='name',
-        help_text='Name of parent site group'
-    )
-
-    class Meta:
-        model = SiteGroup
-        fields = ('name', 'slug', 'parent', 'description')
-
-
-class SiteGroupBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=SiteGroup.objects.all(),
-        widget=forms.MultipleHiddenInput
-    )
-    parent = DynamicModelChoiceField(
-        queryset=SiteGroup.objects.all(),
-        required=False
-    )
-    description = forms.CharField(
-        max_length=200,
-        required=False
-    )
-
-    class Meta:
-        nullable_fields = ['parent', 'description']
-
-
-class SiteGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
-    model = SiteGroup
-    field_groups = [
-        ['q'],
-        ['parent_id'],
-    ]
-    q = forms.CharField(
-        required=False,
-        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
-        label=_('Search')
-    )
-    parent_id = DynamicModelMultipleChoiceField(
-        queryset=SiteGroup.objects.all(),
-        required=False,
-        label=_('Parent group'),
-        fetch_trigger='open'
-    )
-
-
-#
-# Sites
-#
-
-class SiteForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
-    region = DynamicModelChoiceField(
-        queryset=Region.objects.all(),
-        required=False
-    )
-    group = DynamicModelChoiceField(
-        queryset=SiteGroup.objects.all(),
-        required=False
-    )
-    slug = SlugField()
-    time_zone = TimeZoneFormField(
-        choices=add_blank_choice(TimeZoneFormField().choices),
-        required=False,
-        widget=StaticSelect()
-    )
-    comments = CommentField()
-    tags = DynamicModelMultipleChoiceField(
-        queryset=Tag.objects.all(),
-        required=False
-    )
-
-    class Meta:
-        model = Site
-        fields = [
-            'name', 'slug', 'status', 'region', 'group', 'tenant_group', 'tenant', 'facility', 'asn', 'time_zone',
-            'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name',
-            'contact_phone', 'contact_email', 'comments', 'tags',
-        ]
-        fieldsets = (
-            ('Site', (
-                'name', 'slug', 'status', 'region', 'group', 'facility', 'asn', 'time_zone', 'description', 'tags',
-            )),
-            ('Tenancy', ('tenant_group', 'tenant')),
-            ('Contact Info', (
-                'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone',
-                'contact_email',
-            )),
-        )
-        widgets = {
-            'physical_address': SmallTextarea(
-                attrs={
-                    'rows': 3,
-                }
-            ),
-            'shipping_address': SmallTextarea(
-                attrs={
-                    'rows': 3,
-                }
-            ),
-            'status': StaticSelect(),
-            'time_zone': StaticSelect(),
-        }
-        help_texts = {
-            'name': "Full name of the site",
-            'facility': "Data center provider and facility (e.g. Equinix NY7)",
-            'asn': "BGP autonomous system number",
-            'time_zone': "Local time zone",
-            'description': "Short description (will appear in sites list)",
-            'physical_address': "Physical location of the building (e.g. for GPS)",
-            'shipping_address': "If different from the physical address",
-            'latitude': "Latitude in decimal format (xx.yyyyyy)",
-            'longitude': "Longitude in decimal format (xx.yyyyyy)"
-        }
-
-
-class SiteCSVForm(CustomFieldModelCSVForm):
-    status = CSVChoiceField(
-        choices=SiteStatusChoices,
-        required=False,
-        help_text='Operational status'
-    )
-    region = CSVModelChoiceField(
-        queryset=Region.objects.all(),
-        required=False,
-        to_field_name='name',
-        help_text='Assigned region'
-    )
-    group = CSVModelChoiceField(
-        queryset=SiteGroup.objects.all(),
-        required=False,
-        to_field_name='name',
-        help_text='Assigned group'
-    )
-    tenant = CSVModelChoiceField(
-        queryset=Tenant.objects.all(),
-        required=False,
-        to_field_name='name',
-        help_text='Assigned tenant'
-    )
-
-    class Meta:
-        model = Site
-        fields = (
-            'name', 'slug', 'status', 'region', 'group', 'tenant', 'facility', 'asn', 'time_zone', 'description',
-            'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone',
-            'contact_email', 'comments',
-        )
-        help_texts = {
-            'time_zone': mark_safe(
-                'Time zone (<a href="https://en.wikipedia.org/wiki/List_of_tz_database_time_zones">available options</a>)'
-            )
-        }
-
-
-class SiteBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=Site.objects.all(),
-        widget=forms.MultipleHiddenInput
-    )
-    status = forms.ChoiceField(
-        choices=add_blank_choice(SiteStatusChoices),
-        required=False,
-        initial='',
-        widget=StaticSelect()
-    )
-    region = DynamicModelChoiceField(
-        queryset=Region.objects.all(),
-        required=False
-    )
-    group = DynamicModelChoiceField(
-        queryset=SiteGroup.objects.all(),
-        required=False
-    )
-    tenant = DynamicModelChoiceField(
-        queryset=Tenant.objects.all(),
-        required=False
-    )
-    asn = forms.IntegerField(
-        min_value=BGP_ASN_MIN,
-        max_value=BGP_ASN_MAX,
-        required=False,
-        label='ASN'
-    )
-    description = forms.CharField(
-        max_length=100,
-        required=False
-    )
-    time_zone = TimeZoneFormField(
-        choices=add_blank_choice(TimeZoneFormField().choices),
-        required=False,
-        widget=StaticSelect()
-    )
-
-    class Meta:
-        nullable_fields = [
-            'region', 'group', 'tenant', 'asn', 'description', 'time_zone',
-        ]
-
-
-class SiteFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
-    model = Site
-    field_order = ['q', 'status', 'region_id', 'tenant_group_id', 'tenant_id']
-    field_groups = [
-        ['q', 'tag'],
-        ['status', 'region_id', 'group_id'],
-        ['tenant_group_id', 'tenant_id'],
-    ]
-    q = forms.CharField(
-        required=False,
-        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
-        label=_('Search')
-    )
-    status = forms.MultipleChoiceField(
-        choices=SiteStatusChoices,
-        required=False,
-        widget=StaticSelectMultiple(),
-    )
-    region_id = DynamicModelMultipleChoiceField(
-        queryset=Region.objects.all(),
-        required=False,
-        label=_('Region'),
-        fetch_trigger='open'
-    )
-    group_id = DynamicModelMultipleChoiceField(
-        queryset=SiteGroup.objects.all(),
-        required=False,
-        label=_('Site group'),
-        fetch_trigger='open'
-    )
-    tag = TagFilterField(model)
-
-
-#
-# Locations
-#
-
-class LocationForm(BootstrapMixin, CustomFieldModelForm):
-    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(),
-        query_params={
-            'region_id': '$region',
-            'group_id': '$site_group',
-        }
-    )
-    parent = DynamicModelChoiceField(
-        queryset=Location.objects.all(),
-        required=False,
-        query_params={
-            'site_id': '$site'
-        }
-    )
-    slug = SlugField()
-
-    class Meta:
-        model = Location
-        fields = (
-            'region', 'site_group', 'site', 'parent', 'name', 'slug', 'description',
-        )
-
-
-class LocationCSVForm(CustomFieldModelCSVForm):
-    site = CSVModelChoiceField(
-        queryset=Site.objects.all(),
-        to_field_name='name',
-        help_text='Assigned site'
-    )
-    parent = CSVModelChoiceField(
-        queryset=Location.objects.all(),
-        required=False,
-        to_field_name='name',
-        help_text='Parent location',
-        error_messages={
-            'invalid_choice': 'Location not found.',
-        }
-    )
-
-    class Meta:
-        model = Location
-        fields = ('site', 'parent', 'name', 'slug', 'description')
-
-
-class LocationBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=Location.objects.all(),
-        widget=forms.MultipleHiddenInput
-    )
-    site = DynamicModelChoiceField(
-        queryset=Site.objects.all(),
-        required=False
-    )
-    parent = DynamicModelChoiceField(
-        queryset=Location.objects.all(),
-        required=False,
-        query_params={
-            'site_id': '$site'
-        }
-    )
-    description = forms.CharField(
-        max_length=200,
-        required=False
-    )
-
-    class Meta:
-        nullable_fields = ['parent', 'description']
-
-
-class LocationFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
-    model = Location
-    q = forms.CharField(
-        required=False,
-        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
-        label=_('Search')
-    )
-    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,
-        query_params={
-            'region_id': '$region_id',
-            'group_id': '$site_group_id',
-        },
-        label=_('Site'),
-        fetch_trigger='open'
-    )
-    parent_id = DynamicModelMultipleChoiceField(
-        queryset=Location.objects.all(),
-        required=False,
-        query_params={
-            'region_id': '$region_id',
-            'site_id': '$site_id',
-        },
-        label=_('Parent'),
-        fetch_trigger='open'
-    )
-
-
-#
-# Rack roles
-#
-
-class RackRoleForm(BootstrapMixin, CustomFieldModelForm):
-    slug = SlugField()
-
-    class Meta:
-        model = RackRole
-        fields = [
-            'name', 'slug', 'color', 'description',
-        ]
-
-
-class RackRoleCSVForm(CustomFieldModelCSVForm):
-    slug = SlugField()
-
-    class Meta:
-        model = RackRole
-        fields = ('name', 'slug', 'color', 'description')
-        help_texts = {
-            'color': mark_safe('RGB color in hexadecimal (e.g. <code>00ff00</code>)'),
-        }
-
-
-class RackRoleBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=RackRole.objects.all(),
-        widget=forms.MultipleHiddenInput
-    )
-    color = ColorField(
-        required=False
-    )
-    description = forms.CharField(
-        max_length=200,
-        required=False
-    )
-
-    class Meta:
-        nullable_fields = ['color', 'description']
-
-
-class RackRoleFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
-    model = RackRole
-    field_groups = [
-        ['q'],
-    ]
-    q = forms.CharField(
-        required=False,
-        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
-        label=_('Search')
-    )
-
-
-#
-# Racks
-#
-
-class RackForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
-    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(),
-        query_params={
-            'region_id': '$region',
-            'group_id': '$site_group',
-        }
-    )
-    location = DynamicModelChoiceField(
-        queryset=Location.objects.all(),
-        required=False,
-        query_params={
-            'site_id': '$site'
-        }
-    )
-    role = DynamicModelChoiceField(
-        queryset=RackRole.objects.all(),
-        required=False
-    )
-    comments = CommentField()
-    tags = DynamicModelMultipleChoiceField(
-        queryset=Tag.objects.all(),
-        required=False
-    )
-
-    class Meta:
-        model = Rack
-        fields = [
-            'region', 'site_group', 'site', 'location', 'name', 'facility_id', 'tenant_group', 'tenant', 'status',
-            'role', 'serial', 'asset_tag', 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth',
-            'outer_unit', 'comments', 'tags',
-        ]
-        help_texts = {
-            'site': "The site at which the rack exists",
-            'name': "Organizational rack name",
-            'facility_id': "The unique rack ID assigned by the facility",
-            'u_height': "Height in rack units",
-        }
-        widgets = {
-            'status': StaticSelect(),
-            'type': StaticSelect(),
-            'width': StaticSelect(),
-            'outer_unit': StaticSelect(),
-        }
-
-
-class RackCSVForm(CustomFieldModelCSVForm):
-    site = CSVModelChoiceField(
-        queryset=Site.objects.all(),
-        to_field_name='name'
-    )
-    location = CSVModelChoiceField(
-        queryset=Location.objects.all(),
-        required=False,
-        to_field_name='name'
-    )
-    tenant = CSVModelChoiceField(
-        queryset=Tenant.objects.all(),
-        required=False,
-        to_field_name='name',
-        help_text='Name of assigned tenant'
-    )
-    status = CSVChoiceField(
-        choices=RackStatusChoices,
-        required=False,
-        help_text='Operational status'
-    )
-    role = CSVModelChoiceField(
-        queryset=RackRole.objects.all(),
-        required=False,
-        to_field_name='name',
-        help_text='Name of assigned role'
-    )
-    type = CSVChoiceField(
-        choices=RackTypeChoices,
-        required=False,
-        help_text='Rack type'
-    )
-    width = forms.ChoiceField(
-        choices=RackWidthChoices,
-        help_text='Rail-to-rail width (in inches)'
-    )
-    outer_unit = CSVChoiceField(
-        choices=RackDimensionUnitChoices,
-        required=False,
-        help_text='Unit for outer dimensions'
-    )
-
-    class Meta:
-        model = Rack
-        fields = (
-            'site', 'location', 'name', 'facility_id', 'tenant', 'status', 'role', 'type', 'serial', 'asset_tag',
-            'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'comments',
-        )
-
-    def __init__(self, data=None, *args, **kwargs):
-        super().__init__(data, *args, **kwargs)
-
-        if data:
-
-            # Limit location queryset by assigned site
-            params = {f"site__{self.fields['site'].to_field_name}": data.get('site')}
-            self.fields['location'].queryset = self.fields['location'].queryset.filter(**params)
-
-
-class RackBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=Rack.objects.all(),
-        widget=forms.MultipleHiddenInput
-    )
-    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',
-        }
-    )
-    location = DynamicModelChoiceField(
-        queryset=Location.objects.all(),
-        required=False,
-        query_params={
-            'site_id': '$site'
-        }
-    )
-    tenant = DynamicModelChoiceField(
-        queryset=Tenant.objects.all(),
-        required=False
-    )
-    status = forms.ChoiceField(
-        choices=add_blank_choice(RackStatusChoices),
-        required=False,
-        initial='',
-        widget=StaticSelect()
-    )
-    role = DynamicModelChoiceField(
-        queryset=RackRole.objects.all(),
-        required=False
-    )
-    serial = forms.CharField(
-        max_length=50,
-        required=False,
-        label='Serial Number'
-    )
-    asset_tag = forms.CharField(
-        max_length=50,
-        required=False
-    )
-    type = forms.ChoiceField(
-        choices=add_blank_choice(RackTypeChoices),
-        required=False,
-        widget=StaticSelect()
-    )
-    width = forms.ChoiceField(
-        choices=add_blank_choice(RackWidthChoices),
-        required=False,
-        widget=StaticSelect()
-    )
-    u_height = forms.IntegerField(
-        required=False,
-        label='Height (U)'
-    )
-    desc_units = forms.NullBooleanField(
-        required=False,
-        widget=BulkEditNullBooleanSelect,
-        label='Descending units'
-    )
-    outer_width = forms.IntegerField(
-        required=False,
-        min_value=1
-    )
-    outer_depth = forms.IntegerField(
-        required=False,
-        min_value=1
-    )
-    outer_unit = forms.ChoiceField(
-        choices=add_blank_choice(RackDimensionUnitChoices),
-        required=False,
-        widget=StaticSelect()
-    )
-    comments = CommentField(
-        widget=SmallTextarea,
-        label='Comments'
-    )
-
-    class Meta:
-        nullable_fields = [
-            'location', 'tenant', 'role', 'serial', 'asset_tag', 'outer_width', 'outer_depth', 'outer_unit', 'comments',
-        ]
-
-
-class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
-    model = Rack
-    field_order = ['q', 'region_id', 'site_id', 'location_id', 'status', 'role_id', 'tenant_group_id', 'tenant_id']
-    field_groups = [
-        ['q', 'tag'],
-        ['region_id', 'site_id', 'location_id'],
-        ['status', 'role_id'],
-        ['type', 'width', 'serial', 'asset_tag'],
-        ['tenant_group_id', 'tenant_id'],
-    ]
-    q = forms.CharField(
-        required=False,
-        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
-        label=_('Search')
-    )
-    region_id = DynamicModelMultipleChoiceField(
-        queryset=Region.objects.all(),
-        required=False,
-        label=_('Region'),
-        fetch_trigger='open'
-    )
-    site_id = DynamicModelMultipleChoiceField(
-        queryset=Site.objects.all(),
-        required=False,
-        query_params={
-            'region_id': '$region_id'
-        },
-        label=_('Site'),
-        fetch_trigger='open'
-    )
-    location_id = DynamicModelMultipleChoiceField(
-        queryset=Location.objects.all(),
-        required=False,
-        null_option='None',
-        query_params={
-            'site_id': '$site_id'
-        },
-        label=_('Location'),
-        fetch_trigger='open'
-    )
-    status = forms.MultipleChoiceField(
-        choices=RackStatusChoices,
-        required=False,
-        widget=StaticSelectMultiple()
-    )
-    type = forms.MultipleChoiceField(
-        choices=RackTypeChoices,
-        required=False,
-        widget=StaticSelectMultiple()
-    )
-    width = forms.MultipleChoiceField(
-        choices=RackWidthChoices,
-        required=False,
-        widget=StaticSelectMultiple()
-    )
-    role_id = DynamicModelMultipleChoiceField(
-        queryset=RackRole.objects.all(),
-        required=False,
-        null_option='None',
-        label=_('Role'),
-        fetch_trigger='open'
-    )
-    serial = forms.CharField(
-        required=False
-    )
-    asset_tag = forms.CharField(
-        required=False
-    )
-    tag = TagFilterField(model)
-
-
-#
-# Rack elevations
-#
-
-class RackElevationFilterForm(RackFilterForm):
-    field_order = [
-        'q', 'region_id', 'site_id', 'location_id', 'id', 'status', 'role_id', 'tenant_group_id',
-        'tenant_id',
-    ]
-    id = DynamicModelMultipleChoiceField(
-        queryset=Rack.objects.all(),
-        label=_('Rack'),
-        required=False,
-        query_params={
-            'site_id': '$site_id',
-            'location_id': '$location_id',
-        },
-        fetch_trigger='open'
-    )
-
-
-#
-# Rack reservations
-#
-
-class RackReservationForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
-    region = DynamicModelChoiceField(
-        queryset=Region.objects.all(),
-        required=False,
-        initial_params={
-            'sites': '$site'
-        },
-        fetch_trigger='open'
-    )
-    site_group = DynamicModelChoiceField(
-        queryset=SiteGroup.objects.all(),
-        required=False,
-        initial_params={
-            'sites': '$site'
-        },
-        fetch_trigger='open'
-    )
-    site = DynamicModelChoiceField(
-        queryset=Site.objects.all(),
-        required=False,
-        query_params={
-            'region_id': '$region',
-            'group_id': '$site_group',
-        },
-        fetch_trigger='open'
-    )
-    location = DynamicModelChoiceField(
-        queryset=Location.objects.all(),
-        required=False,
-        query_params={
-            'site_id': '$site'
-        },
-        fetch_trigger='open'
-    )
-    rack = DynamicModelChoiceField(
-        queryset=Rack.objects.all(),
-        query_params={
-            'site_id': '$site',
-            'location_id': '$location',
-        },
-        fetch_trigger='open'
-    )
-    units = NumericArrayField(
-        base_field=forms.IntegerField(),
-        help_text="Comma-separated list of numeric unit IDs. A range may be specified using a hyphen."
-    )
-    user = forms.ModelChoiceField(
-        queryset=User.objects.order_by(
-            'username'
-        ),
-        widget=StaticSelect()
-    )
-    tags = DynamicModelMultipleChoiceField(
-        queryset=Tag.objects.all(),
-        required=False,
-        fetch_trigger='open'
-    )
-
-    class Meta:
-        model = RackReservation
-        fields = [
-            'region', 'site_group', 'site', 'location', 'rack', 'units', 'user', 'tenant_group', 'tenant',
-            'description', 'tags',
-        ]
-        fieldsets = (
-            ('Reservation', ('region', 'site', 'location', 'rack', 'units', 'user', 'description', 'tags')),
-            ('Tenancy', ('tenant_group', 'tenant')),
-        )
-
-
-class RackReservationCSVForm(CustomFieldModelCSVForm):
-    site = CSVModelChoiceField(
-        queryset=Site.objects.all(),
-        to_field_name='name',
-        help_text='Parent site'
-    )
-    location = CSVModelChoiceField(
-        queryset=Location.objects.all(),
-        to_field_name='name',
-        required=False,
-        help_text="Rack's location (if any)"
-    )
-    rack = CSVModelChoiceField(
-        queryset=Rack.objects.all(),
-        to_field_name='name',
-        help_text='Rack'
-    )
-    units = SimpleArrayField(
-        base_field=forms.IntegerField(),
-        required=True,
-        help_text='Comma-separated list of individual unit numbers'
-    )
-    tenant = CSVModelChoiceField(
-        queryset=Tenant.objects.all(),
-        required=False,
-        to_field_name='name',
-        help_text='Assigned tenant'
-    )
-
-    class Meta:
-        model = RackReservation
-        fields = ('site', 'location', 'rack', 'units', 'tenant', 'description')
-
-    def __init__(self, data=None, *args, **kwargs):
-        super().__init__(data, *args, **kwargs)
-
-        if data:
-
-            # Limit location queryset by assigned site
-            params = {f"site__{self.fields['site'].to_field_name}": data.get('site')}
-            self.fields['location'].queryset = self.fields['location'].queryset.filter(**params)
-
-            # Limit rack queryset by assigned site and group
-            params = {
-                f"site__{self.fields['site'].to_field_name}": data.get('site'),
-                f"location__{self.fields['location'].to_field_name}": data.get('location'),
-            }
-            self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params)
-
-
-class RackReservationBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=RackReservation.objects.all(),
-        widget=forms.MultipleHiddenInput()
-    )
-    user = forms.ModelChoiceField(
-        queryset=User.objects.order_by(
-            'username'
-        ),
-        required=False,
-        widget=StaticSelect()
-    )
-    tenant = DynamicModelChoiceField(
-        queryset=Tenant.objects.all(),
-        required=False
-    )
-    description = forms.CharField(
-        max_length=100,
-        required=False
-    )
-
-    class Meta:
-        nullable_fields = []
-
-
-class RackReservationFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
-    model = RackReservation
-    field_order = ['q', 'region_id', 'site_id', 'location_id', 'user_id', 'tenant_group_id', 'tenant_id']
-    field_groups = [
-        ['q', 'tag'],
-        ['user_id'],
-        ['region_id', 'site_id', 'location_id'],
-        ['tenant_group_id', 'tenant_id'],
-    ]
-    q = forms.CharField(
-        required=False,
-        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
-        label=_('Search')
-    )
-    region_id = DynamicModelMultipleChoiceField(
-        queryset=Region.objects.all(),
-        required=False,
-        label=_('Region'),
-        fetch_trigger='open'
-    )
-    site_id = DynamicModelMultipleChoiceField(
-        queryset=Site.objects.all(),
-        required=False,
-        query_params={
-            'region_id': '$region_id'
-        },
-        label=_('Site'),
-        fetch_trigger='open'
-    )
-    location_id = DynamicModelMultipleChoiceField(
-        queryset=Location.objects.prefetch_related('site'),
-        required=False,
-        label=_('Location'),
-        null_option='None',
-        fetch_trigger='open'
-    )
-    user_id = DynamicModelMultipleChoiceField(
-        queryset=User.objects.all(),
-        required=False,
-        label=_('User'),
-        widget=APISelectMultiple(
-            api_url='/api/users/users/',
-        ),
-        fetch_trigger='open'
-    )
-    tag = TagFilterField(model)
-
-
-#
-# Manufacturers
-#
-
-class ManufacturerForm(BootstrapMixin, CustomFieldModelForm):
-    slug = SlugField()
-
-    class Meta:
-        model = Manufacturer
-        fields = [
-            'name', 'slug', 'description',
-        ]
-
-
-class ManufacturerCSVForm(CustomFieldModelCSVForm):
-
-    class Meta:
-        model = Manufacturer
-        fields = ('name', 'slug', 'description')
-
-
-class ManufacturerBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=Manufacturer.objects.all(),
-        widget=forms.MultipleHiddenInput
-    )
-    description = forms.CharField(
-        max_length=200,
-        required=False
-    )
-
-    class Meta:
-        nullable_fields = ['description']
-
-
-class ManufacturerFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
-    model = Manufacturer
-    field_groups = [
-        ['q'],
-    ]
-    q = forms.CharField(
-        required=False,
-        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
-        label=_('Search')
-    )
-
-
-#
-# Device types
-#
-
-class DeviceTypeForm(BootstrapMixin, CustomFieldModelForm):
-    manufacturer = DynamicModelChoiceField(
-        queryset=Manufacturer.objects.all()
-    )
-    slug = SlugField(
-        slug_source='model'
-    )
-    comments = CommentField()
-    tags = DynamicModelMultipleChoiceField(
-        queryset=Tag.objects.all(),
-        required=False
-    )
-
-    class Meta:
-        model = DeviceType
-        fields = [
-            'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role',
-            'front_image', 'rear_image', 'comments', 'tags',
-        ]
-        fieldsets = (
-            ('Device Type', (
-                'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'tags',
-            )),
-            ('Images', ('front_image', 'rear_image')),
-        )
-        widgets = {
-            'subdevice_role': StaticSelect(),
-            'front_image': ClearableFileInput(attrs={
-                'accept': DEVICETYPE_IMAGE_FORMATS
-            }),
-            'rear_image': ClearableFileInput(attrs={
-                'accept': DEVICETYPE_IMAGE_FORMATS
-            })
-        }
-
-
-class DeviceTypeImportForm(BootstrapMixin, forms.ModelForm):
-    manufacturer = forms.ModelChoiceField(
-        queryset=Manufacturer.objects.all(),
-        to_field_name='name'
-    )
-
-    class Meta:
-        model = DeviceType
-        fields = [
-            'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role',
-            'comments',
-        ]
-
-
-class DeviceTypeBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=DeviceType.objects.all(),
-        widget=forms.MultipleHiddenInput()
-    )
-    manufacturer = DynamicModelChoiceField(
-        queryset=Manufacturer.objects.all(),
-        required=False
-    )
-    u_height = forms.IntegerField(
-        min_value=1,
-        required=False
-    )
-    is_full_depth = forms.NullBooleanField(
-        required=False,
-        widget=BulkEditNullBooleanSelect(),
-        label='Is full depth'
-    )
-
-    class Meta:
-        nullable_fields = []
-
-
-class DeviceTypeFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
-    model = DeviceType
-    field_groups = [
-        ['q', 'tag'],
-        ['manufacturer_id', 'subdevice_role'],
-        ['console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports'],
-    ]
-    q = forms.CharField(
-        required=False,
-        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
-        label=_('Search')
-    )
-    manufacturer_id = DynamicModelMultipleChoiceField(
-        queryset=Manufacturer.objects.all(),
-        required=False,
-        label=_('Manufacturer'),
-        fetch_trigger='open'
-    )
-    subdevice_role = forms.MultipleChoiceField(
-        choices=add_blank_choice(SubdeviceRoleChoices),
-        required=False,
-        widget=StaticSelectMultiple()
-    )
-    console_ports = forms.NullBooleanField(
-        required=False,
-        label='Has console ports',
-        widget=StaticSelect(
-            choices=BOOLEAN_WITH_BLANK_CHOICES
-        )
-    )
-    console_server_ports = forms.NullBooleanField(
-        required=False,
-        label='Has console server ports',
-        widget=StaticSelect(
-            choices=BOOLEAN_WITH_BLANK_CHOICES
-        )
-    )
-    power_ports = forms.NullBooleanField(
-        required=False,
-        label='Has power ports',
-        widget=StaticSelect(
-            choices=BOOLEAN_WITH_BLANK_CHOICES
-        )
-    )
-    power_outlets = forms.NullBooleanField(
-        required=False,
-        label='Has power outlets',
-        widget=StaticSelect(
-            choices=BOOLEAN_WITH_BLANK_CHOICES
-        )
-    )
-    interfaces = forms.NullBooleanField(
-        required=False,
-        label='Has interfaces',
-        widget=StaticSelect(
-            choices=BOOLEAN_WITH_BLANK_CHOICES
-        )
-    )
-    pass_through_ports = forms.NullBooleanField(
-        required=False,
-        label='Has pass-through ports',
-        widget=StaticSelect(
-            choices=BOOLEAN_WITH_BLANK_CHOICES
-        )
-    )
-    tag = TagFilterField(model)
-
-
-#
-# Device component templates
-#
-
-class ComponentTemplateCreateForm(BootstrapMixin, ComponentForm):
-    """
-    Base form for the creation of device component templates (subclassed from ComponentTemplateModel).
-    """
-    manufacturer = DynamicModelChoiceField(
-        queryset=Manufacturer.objects.all(),
-        required=False,
-        initial_params={
-            'device_types': 'device_type'
-        }
-    )
-    device_type = DynamicModelChoiceField(
-        queryset=DeviceType.objects.all(),
-        query_params={
-            'manufacturer_id': '$manufacturer'
-        }
-    )
-    description = forms.CharField(
-        required=False
-    )
-
-
-class ConsolePortTemplateForm(BootstrapMixin, forms.ModelForm):
-
-    class Meta:
-        model = ConsolePortTemplate
-        fields = [
-            'device_type', 'name', 'label', 'type', 'description',
-        ]
-        widgets = {
-            'device_type': forms.HiddenInput(),
-        }
-
-
-class ConsolePortTemplateCreateForm(ComponentTemplateCreateForm):
-    type = forms.ChoiceField(
-        choices=add_blank_choice(ConsolePortTypeChoices),
-        widget=StaticSelect()
-    )
-    field_order = ('manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'type', 'description')
-
-
-class ConsolePortTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=ConsolePortTemplate.objects.all(),
-        widget=forms.MultipleHiddenInput()
-    )
-    label = forms.CharField(
-        max_length=64,
-        required=False
-    )
-    type = forms.ChoiceField(
-        choices=add_blank_choice(ConsolePortTypeChoices),
-        required=False,
-        widget=StaticSelect()
-    )
-
-    class Meta:
-        nullable_fields = ('label', 'type', 'description')
-
-
-class ConsoleServerPortTemplateForm(BootstrapMixin, forms.ModelForm):
-
-    class Meta:
-        model = ConsoleServerPortTemplate
-        fields = [
-            'device_type', 'name', 'label', 'type', 'description',
-        ]
-        widgets = {
-            'device_type': forms.HiddenInput(),
-        }
-
-
-class ConsoleServerPortTemplateCreateForm(ComponentTemplateCreateForm):
-    type = forms.ChoiceField(
-        choices=add_blank_choice(ConsolePortTypeChoices),
-        widget=StaticSelect()
-    )
-    field_order = ('manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'type', 'description')
-
-
-class ConsoleServerPortTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=ConsoleServerPortTemplate.objects.all(),
-        widget=forms.MultipleHiddenInput()
-    )
-    label = forms.CharField(
-        max_length=64,
-        required=False
-    )
-    type = forms.ChoiceField(
-        choices=add_blank_choice(ConsolePortTypeChoices),
-        required=False,
-        widget=StaticSelect()
-    )
-    description = forms.CharField(
-        required=False
-    )
-
-    class Meta:
-        nullable_fields = ('label', 'type', 'description')
-
-
-class PowerPortTemplateForm(BootstrapMixin, forms.ModelForm):
-
-    class Meta:
-        model = PowerPortTemplate
-        fields = [
-            'device_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description',
-        ]
-        widgets = {
-            'device_type': forms.HiddenInput(),
-        }
-
-
-class PowerPortTemplateCreateForm(ComponentTemplateCreateForm):
-    type = forms.ChoiceField(
-        choices=add_blank_choice(PowerPortTypeChoices),
-        required=False
-    )
-    maximum_draw = forms.IntegerField(
-        min_value=1,
-        required=False,
-        help_text="Maximum power draw (watts)"
-    )
-    allocated_draw = forms.IntegerField(
-        min_value=1,
-        required=False,
-        help_text="Allocated power draw (watts)"
-    )
-    field_order = (
-        'manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'type', 'maximum_draw', 'allocated_draw',
-        'description',
-    )
-
-
-class PowerPortTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=PowerPortTemplate.objects.all(),
-        widget=forms.MultipleHiddenInput()
-    )
-    label = forms.CharField(
-        max_length=64,
-        required=False
-    )
-    type = forms.ChoiceField(
-        choices=add_blank_choice(PowerPortTypeChoices),
-        required=False,
-        widget=StaticSelect()
-    )
-    maximum_draw = forms.IntegerField(
-        min_value=1,
-        required=False,
-        help_text="Maximum power draw (watts)"
-    )
-    allocated_draw = forms.IntegerField(
-        min_value=1,
-        required=False,
-        help_text="Allocated power draw (watts)"
-    )
-    description = forms.CharField(
-        required=False
-    )
-
-    class Meta:
-        nullable_fields = ('label', 'type', 'maximum_draw', 'allocated_draw', 'description')
-
-
-class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm):
-
-    class Meta:
-        model = PowerOutletTemplate
-        fields = [
-            'device_type', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description',
-        ]
-        widgets = {
-            'device_type': forms.HiddenInput(),
-        }
-
-    def __init__(self, *args, **kwargs):
-
-        super().__init__(*args, **kwargs)
-
-        # Limit power_port choices to current DeviceType
-        if hasattr(self.instance, 'device_type'):
-            self.fields['power_port'].queryset = PowerPortTemplate.objects.filter(
-                device_type=self.instance.device_type
-            )
-
-
-class PowerOutletTemplateCreateForm(ComponentTemplateCreateForm):
-    type = forms.ChoiceField(
-        choices=add_blank_choice(PowerOutletTypeChoices),
-        required=False
-    )
-    power_port = forms.ModelChoiceField(
-        queryset=PowerPortTemplate.objects.all(),
-        required=False
-    )
-    feed_leg = forms.ChoiceField(
-        choices=add_blank_choice(PowerOutletFeedLegChoices),
-        required=False,
-        widget=StaticSelect()
-    )
-    field_order = (
-        'manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'type', 'power_port', 'feed_leg',
-        'description',
-    )
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-
-        # Limit power_port choices to current DeviceType
-        device_type = DeviceType.objects.get(
-            pk=self.initial.get('device_type') or self.data.get('device_type')
-        )
-        self.fields['power_port'].queryset = PowerPortTemplate.objects.filter(
-            device_type=device_type
-        )
-
-
-class PowerOutletTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=PowerOutletTemplate.objects.all(),
-        widget=forms.MultipleHiddenInput()
-    )
-    device_type = forms.ModelChoiceField(
-        queryset=DeviceType.objects.all(),
-        required=False,
-        disabled=True,
-        widget=forms.HiddenInput()
-    )
-    label = forms.CharField(
-        max_length=64,
-        required=False
-    )
-    type = forms.ChoiceField(
-        choices=add_blank_choice(PowerOutletTypeChoices),
-        required=False,
-        widget=StaticSelect()
-    )
-    power_port = forms.ModelChoiceField(
-        queryset=PowerPortTemplate.objects.all(),
-        required=False
-    )
-    feed_leg = forms.ChoiceField(
-        choices=add_blank_choice(PowerOutletFeedLegChoices),
-        required=False,
-        widget=StaticSelect()
-    )
-    description = forms.CharField(
-        required=False
-    )
-
-    class Meta:
-        nullable_fields = ('label', 'type', 'power_port', 'feed_leg', 'description')
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-
-        # Limit power_port queryset to PowerPortTemplates which belong to the parent DeviceType
-        if 'device_type' in self.initial:
-            device_type = DeviceType.objects.filter(pk=self.initial['device_type']).first()
-            self.fields['power_port'].queryset = PowerPortTemplate.objects.filter(device_type=device_type)
-        else:
-            self.fields['power_port'].choices = ()
-            self.fields['power_port'].widget.attrs['disabled'] = True
-
-
-class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm):
-
-    class Meta:
-        model = InterfaceTemplate
-        fields = [
-            'device_type', 'name', 'label', 'type', 'mgmt_only', 'description',
-        ]
-        widgets = {
-            'device_type': forms.HiddenInput(),
-            'type': StaticSelect(),
-        }
-
-
-class InterfaceTemplateCreateForm(ComponentTemplateCreateForm):
-    type = forms.ChoiceField(
-        choices=InterfaceTypeChoices,
-        widget=StaticSelect()
-    )
-    mgmt_only = forms.BooleanField(
-        required=False,
-        label='Management only'
-    )
-    field_order = ('manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'type', 'mgmt_only', 'description')
-
-
-class InterfaceTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=InterfaceTemplate.objects.all(),
-        widget=forms.MultipleHiddenInput()
-    )
-    label = forms.CharField(
-        max_length=64,
-        required=False
-    )
-    type = forms.ChoiceField(
-        choices=add_blank_choice(InterfaceTypeChoices),
-        required=False,
-        widget=StaticSelect()
-    )
-    mgmt_only = forms.NullBooleanField(
-        required=False,
-        widget=BulkEditNullBooleanSelect,
-        label='Management only'
-    )
-    description = forms.CharField(
-        required=False
-    )
-
-    class Meta:
-        nullable_fields = ('label', 'description')
-
-
-class FrontPortTemplateForm(BootstrapMixin, forms.ModelForm):
-
-    class Meta:
-        model = FrontPortTemplate
-        fields = [
-            'device_type', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description',
-        ]
-        widgets = {
-            'device_type': forms.HiddenInput(),
-            'rear_port': StaticSelect(),
-        }
-
-    def __init__(self, *args, **kwargs):
-
-        super().__init__(*args, **kwargs)
-
-        # Limit rear_port choices to current DeviceType
-        if hasattr(self.instance, 'device_type'):
-            self.fields['rear_port'].queryset = RearPortTemplate.objects.filter(
-                device_type=self.instance.device_type
-            )
-
-
-class FrontPortTemplateCreateForm(ComponentTemplateCreateForm):
-    type = forms.ChoiceField(
-        choices=PortTypeChoices,
-        widget=StaticSelect()
-    )
-    color = ColorField(
-        required=False
-    )
-    rear_port_set = forms.MultipleChoiceField(
-        choices=[],
-        label='Rear ports',
-        help_text='Select one rear port assignment for each front port being created.',
-    )
-    field_order = (
-        'manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'type', 'color', 'rear_port_set', 'description',
-    )
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-
-        device_type = DeviceType.objects.get(
-            pk=self.initial.get('device_type') or self.data.get('device_type')
-        )
-
-        # Determine which rear port positions are occupied. These will be excluded from the list of available mappings.
-        occupied_port_positions = [
-            (front_port.rear_port_id, front_port.rear_port_position)
-            for front_port in device_type.frontporttemplates.all()
-        ]
-
-        # Populate rear port choices
-        choices = []
-        rear_ports = RearPortTemplate.objects.filter(device_type=device_type)
-        for rear_port in rear_ports:
-            for i in range(1, rear_port.positions + 1):
-                if (rear_port.pk, i) not in occupied_port_positions:
-                    choices.append(
-                        ('{}:{}'.format(rear_port.pk, i), '{}:{}'.format(rear_port.name, i))
-                    )
-        self.fields['rear_port_set'].choices = choices
-
-    def clean(self):
-        super().clean()
-
-        # Validate that the number of ports being created equals the number of selected (rear port, position) tuples
-        front_port_count = len(self.cleaned_data['name_pattern'])
-        rear_port_count = len(self.cleaned_data['rear_port_set'])
-        if front_port_count != rear_port_count:
-            raise forms.ValidationError({
-                'rear_port_set': 'The provided name pattern will create {} ports, however {} rear port assignments '
-                                 'were selected. These counts must match.'.format(front_port_count, rear_port_count)
-            })
-
-    def get_iterative_data(self, iteration):
-
-        # Assign rear port and position from selected set
-        rear_port, position = self.cleaned_data['rear_port_set'][iteration].split(':')
-
-        return {
-            'rear_port': int(rear_port),
-            'rear_port_position': int(position),
-        }
-
-
-class FrontPortTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=FrontPortTemplate.objects.all(),
-        widget=forms.MultipleHiddenInput()
-    )
-    label = forms.CharField(
-        max_length=64,
-        required=False
-    )
-    type = forms.ChoiceField(
-        choices=add_blank_choice(PortTypeChoices),
-        required=False,
-        widget=StaticSelect()
-    )
-    color = ColorField(
-        required=False
-    )
-    description = forms.CharField(
-        required=False
-    )
-
-    class Meta:
-        nullable_fields = ('description',)
-
-
-class RearPortTemplateForm(BootstrapMixin, forms.ModelForm):
-
-    class Meta:
-        model = RearPortTemplate
-        fields = [
-            'device_type', 'name', 'label', 'type', 'color', 'positions', 'description',
-        ]
-        widgets = {
-            'device_type': forms.HiddenInput(),
-            'type': StaticSelect(),
-        }
-
-
-class RearPortTemplateCreateForm(ComponentTemplateCreateForm):
-    type = forms.ChoiceField(
-        choices=PortTypeChoices,
-        widget=StaticSelect(),
-    )
-    color = ColorField(
-        required=False
-    )
-    positions = forms.IntegerField(
-        min_value=REARPORT_POSITIONS_MIN,
-        max_value=REARPORT_POSITIONS_MAX,
-        initial=1,
-        help_text='The number of front ports which may be mapped to each rear port'
-    )
-    field_order = (
-        'manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'type', 'color', 'positions', 'description',
-    )
-
-
-class RearPortTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=RearPortTemplate.objects.all(),
-        widget=forms.MultipleHiddenInput()
-    )
-    label = forms.CharField(
-        max_length=64,
-        required=False
-    )
-    type = forms.ChoiceField(
-        choices=add_blank_choice(PortTypeChoices),
-        required=False,
-        widget=StaticSelect()
-    )
-    color = ColorField(
-        required=False
-    )
-    description = forms.CharField(
-        required=False
-    )
-
-    class Meta:
-        nullable_fields = ('description',)
-
-
-class DeviceBayTemplateForm(BootstrapMixin, forms.ModelForm):
-
-    class Meta:
-        model = DeviceBayTemplate
-        fields = [
-            'device_type', 'name', 'label', 'description',
-        ]
-        widgets = {
-            'device_type': forms.HiddenInput(),
-        }
-
-
-class DeviceBayTemplateCreateForm(ComponentTemplateCreateForm):
-    field_order = ('manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'description')
-
-
-class DeviceBayTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=DeviceBayTemplate.objects.all(),
-        widget=forms.MultipleHiddenInput()
-    )
-    label = forms.CharField(
-        max_length=64,
-        required=False
-    )
-    description = forms.CharField(
-        required=False
-    )
-
-    class Meta:
-        nullable_fields = ('label', 'description')
-
-
-#
-# Component template import forms
-#
-
-class ComponentTemplateImportForm(BootstrapMixin, forms.ModelForm):
-
-    def __init__(self, device_type, data=None, *args, **kwargs):
-
-        # Must pass the parent DeviceType on form initialization
-        data.update({
-            'device_type': device_type.pk,
-        })
-
-        super().__init__(data, *args, **kwargs)
-
-    def clean_device_type(self):
-
-        data = self.cleaned_data['device_type']
-
-        # Limit fields referencing other components to the parent DeviceType
-        for field_name, field in self.fields.items():
-            if isinstance(field, forms.ModelChoiceField) and field_name != 'device_type':
-                field.queryset = field.queryset.filter(device_type=data)
-
-        return data
-
-
-class ConsolePortTemplateImportForm(ComponentTemplateImportForm):
-
-    class Meta:
-        model = ConsolePortTemplate
-        fields = [
-            'device_type', 'name', 'label', 'type', 'description',
-        ]
-
-
-class ConsoleServerPortTemplateImportForm(ComponentTemplateImportForm):
-
-    class Meta:
-        model = ConsoleServerPortTemplate
-        fields = [
-            'device_type', 'name', 'label', 'type', 'description',
-        ]
-
-
-class PowerPortTemplateImportForm(ComponentTemplateImportForm):
-
-    class Meta:
-        model = PowerPortTemplate
-        fields = [
-            'device_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description',
-        ]
-
-
-class PowerOutletTemplateImportForm(ComponentTemplateImportForm):
-    power_port = forms.ModelChoiceField(
-        queryset=PowerPortTemplate.objects.all(),
-        to_field_name='name',
-        required=False
-    )
-
-    class Meta:
-        model = PowerOutletTemplate
-        fields = [
-            'device_type', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description',
-        ]
-
-
-class InterfaceTemplateImportForm(ComponentTemplateImportForm):
-    type = forms.ChoiceField(
-        choices=InterfaceTypeChoices.CHOICES
-    )
-
-    class Meta:
-        model = InterfaceTemplate
-        fields = [
-            'device_type', 'name', 'label', 'type', 'mgmt_only', 'description',
-        ]
-
-
-class FrontPortTemplateImportForm(ComponentTemplateImportForm):
-    type = forms.ChoiceField(
-        choices=PortTypeChoices.CHOICES
-    )
-    rear_port = forms.ModelChoiceField(
-        queryset=RearPortTemplate.objects.all(),
-        to_field_name='name'
-    )
-
-    class Meta:
-        model = FrontPortTemplate
-        fields = [
-            'device_type', 'name', 'type', 'rear_port', 'rear_port_position', 'label', 'description',
-        ]
-
-
-class RearPortTemplateImportForm(ComponentTemplateImportForm):
-    type = forms.ChoiceField(
-        choices=PortTypeChoices.CHOICES
-    )
-
-    class Meta:
-        model = RearPortTemplate
-        fields = [
-            'device_type', 'name', 'type', 'positions', 'label', 'description',
-        ]
-
-
-class DeviceBayTemplateImportForm(ComponentTemplateImportForm):
-
-    class Meta:
-        model = DeviceBayTemplate
-        fields = [
-            'device_type', 'name', 'label', 'description',
-        ]
-
-
-#
-# Device roles
-#
-
-class DeviceRoleForm(BootstrapMixin, CustomFieldModelForm):
-    slug = SlugField()
-
-    class Meta:
-        model = DeviceRole
-        fields = [
-            'name', 'slug', 'color', 'vm_role', 'description',
-        ]
-
-
-class DeviceRoleCSVForm(CustomFieldModelCSVForm):
-    slug = SlugField()
-
-    class Meta:
-        model = DeviceRole
-        fields = ('name', 'slug', 'color', 'vm_role', 'description')
-        help_texts = {
-            'color': mark_safe('RGB color in hexadecimal (e.g. <code>00ff00</code>)'),
-        }
-
-
-class DeviceRoleBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=DeviceRole.objects.all(),
-        widget=forms.MultipleHiddenInput
-    )
-    color = ColorField(
-        required=False
-    )
-    vm_role = forms.NullBooleanField(
-        required=False,
-        widget=BulkEditNullBooleanSelect,
-        label='VM role'
-    )
-    description = forms.CharField(
-        max_length=200,
-        required=False
-    )
-
-    class Meta:
-        nullable_fields = ['color', 'description']
-
-
-class DeviceRoleFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
-    model = DeviceRole
-    field_groups = [
-        ['q'],
-    ]
-    q = forms.CharField(
-        required=False,
-        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
-        label=_('Search')
-    )
-
-
-#
-# Platforms
-#
-
-class PlatformForm(BootstrapMixin, CustomFieldModelForm):
-    manufacturer = DynamicModelChoiceField(
-        queryset=Manufacturer.objects.all(),
-        required=False
-    )
-    slug = SlugField(
-        max_length=64
-    )
-
-    class Meta:
-        model = Platform
-        fields = [
-            'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description',
-        ]
-        widgets = {
-            'napalm_args': SmallTextarea(),
-        }
-
-
-class PlatformCSVForm(CustomFieldModelCSVForm):
-    slug = SlugField()
-    manufacturer = CSVModelChoiceField(
-        queryset=Manufacturer.objects.all(),
-        required=False,
-        to_field_name='name',
-        help_text='Limit platform assignments to this manufacturer'
-    )
-
-    class Meta:
-        model = Platform
-        fields = ('name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description')
-
-
-class PlatformBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=Platform.objects.all(),
-        widget=forms.MultipleHiddenInput
-    )
-    manufacturer = DynamicModelChoiceField(
-        queryset=Manufacturer.objects.all(),
-        required=False
-    )
-    napalm_driver = forms.CharField(
-        max_length=50,
-        required=False
-    )
-    # TODO: Bulk edit support for napalm_args
-    description = forms.CharField(
-        max_length=200,
-        required=False
-    )
-
-    class Meta:
-        nullable_fields = ['manufacturer', 'napalm_driver', 'description']
-
-
-class PlatformFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
-    model = Platform
-    q = forms.CharField(
-        required=False,
-        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
-        label=_('Search')
-    )
-    manufacturer_id = DynamicModelMultipleChoiceField(
-        queryset=Manufacturer.objects.all(),
-        required=False,
-        label=_('Manufacturer'),
-        fetch_trigger='open'
-    )
-
-
-#
-# Devices
-#
-
-class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
-    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(),
-        query_params={
-            'region_id': '$region',
-            'group_id': '$site_group',
-        }
-    )
-    location = DynamicModelChoiceField(
-        queryset=Location.objects.all(),
-        required=False,
-        query_params={
-            'site_id': '$site'
-        },
-        initial_params={
-            'racks': '$rack'
-        }
-    )
-    rack = DynamicModelChoiceField(
-        queryset=Rack.objects.all(),
-        required=False,
-        query_params={
-            'site_id': '$site',
-            'location_id': '$location',
-        }
-    )
-    position = forms.IntegerField(
-        required=False,
-        help_text="The lowest-numbered unit occupied by the device",
-        widget=APISelect(
-            api_url='/api/dcim/racks/{{rack}}/elevation/',
-            attrs={
-                'disabled-indicator': 'device',
-                'data-dynamic-params': '[{"fieldName":"face","queryParam":"face"}]'
-            }
-        )
-    )
-    manufacturer = DynamicModelChoiceField(
-        queryset=Manufacturer.objects.all(),
-        required=False,
-        initial_params={
-            'device_types': '$device_type'
-        }
-    )
-    device_type = DynamicModelChoiceField(
-        queryset=DeviceType.objects.all(),
-        query_params={
-            'manufacturer_id': '$manufacturer'
-        }
-    )
-    device_role = DynamicModelChoiceField(
-        queryset=DeviceRole.objects.all()
-    )
-    platform = DynamicModelChoiceField(
-        queryset=Platform.objects.all(),
-        required=False,
-        query_params={
-            'manufacturer_id': ['$manufacturer', 'null']
-        }
-    )
-    cluster_group = DynamicModelChoiceField(
-        queryset=ClusterGroup.objects.all(),
-        required=False,
-        null_option='None',
-        initial_params={
-            'clusters': '$cluster'
-        }
-    )
-    cluster = DynamicModelChoiceField(
-        queryset=Cluster.objects.all(),
-        required=False,
-        query_params={
-            'group_id': '$cluster_group'
-        }
-    )
-    comments = CommentField()
-    local_context_data = JSONField(
-        required=False,
-        label=''
-    )
-    tags = DynamicModelMultipleChoiceField(
-        queryset=Tag.objects.all(),
-        required=False
-    )
-
-    class Meta:
-        model = Device
-        fields = [
-            'name', 'device_role', 'device_type', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'rack',
-            'location', 'position', 'face', 'status', 'platform', 'primary_ip4', 'primary_ip6', 'cluster_group',
-            'cluster', 'tenant_group', 'tenant', 'comments', 'tags', 'local_context_data'
-        ]
-        help_texts = {
-            'device_role': "The function this device serves",
-            'serial': "Chassis serial number",
-            'local_context_data': "Local config context data overwrites all source contexts in the final rendered "
-                                  "config context",
-        }
-        widgets = {
-            'face': StaticSelect(),
-            '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 Device or a peer VirtualChassis member
-                interface_ids = self.instance.vc_interfaces(if_master=False).values_list('pk', flat=True)
-
-                # Collect interface IPs
-                interface_ips = IPAddress.objects.filter(
-                    address__family=family,
-                    assigned_object_type=ContentType.objects.get_for_model(Interface),
-                    assigned_object_id__in=interface_ids
-                ).prefetch_related('assigned_object')
-                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(Interface),
-                    nat_inside__assigned_object_id__in=interface_ids
-                ).prefetch_related('assigned_object')
-                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
-
-            # If editing an existing device, exclude it from the list of occupied rack units. This ensures that a device
-            # can be flipped from one face to another.
-            self.fields['position'].widget.add_query_param('exclude', self.instance.pk)
-
-            # Limit platform by manufacturer
-            self.fields['platform'].queryset = Platform.objects.filter(
-                Q(manufacturer__isnull=True) | Q(manufacturer=self.instance.device_type.manufacturer)
-            )
-
-            # Disable rack assignment if this is a child device installed in a parent device
-            if self.instance.device_type.is_child_device and hasattr(self.instance, 'parent_bay'):
-                self.fields['site'].disabled = True
-                self.fields['rack'].disabled = True
-                self.initial['site'] = self.instance.parent_bay.device.site_id
-                self.initial['rack'] = self.instance.parent_bay.device.rack_id
-
-        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
-
-        # Rack position
-        position = self.data.get('position') or self.initial.get('position')
-        if position:
-            self.fields['position'].widget.choices = [(position, f'U{position}')]
-
-
-class BaseDeviceCSVForm(CustomFieldModelCSVForm):
-    device_role = CSVModelChoiceField(
-        queryset=DeviceRole.objects.all(),
-        to_field_name='name',
-        help_text='Assigned role'
-    )
-    tenant = CSVModelChoiceField(
-        queryset=Tenant.objects.all(),
-        required=False,
-        to_field_name='name',
-        help_text='Assigned tenant'
-    )
-    manufacturer = CSVModelChoiceField(
-        queryset=Manufacturer.objects.all(),
-        to_field_name='name',
-        help_text='Device type manufacturer'
-    )
-    device_type = CSVModelChoiceField(
-        queryset=DeviceType.objects.all(),
-        to_field_name='model',
-        help_text='Device type model'
-    )
-    platform = CSVModelChoiceField(
-        queryset=Platform.objects.all(),
-        required=False,
-        to_field_name='name',
-        help_text='Assigned platform'
-    )
-    status = CSVChoiceField(
-        choices=DeviceStatusChoices,
-        help_text='Operational status'
-    )
-    virtual_chassis = CSVModelChoiceField(
-        queryset=VirtualChassis.objects.all(),
-        to_field_name='name',
-        required=False,
-        help_text='Virtual chassis'
-    )
-    cluster = CSVModelChoiceField(
-        queryset=Cluster.objects.all(),
-        to_field_name='name',
-        required=False,
-        help_text='Virtualization cluster'
-    )
-
-    class Meta:
-        fields = []
-        model = Device
-        help_texts = {
-            'vc_position': 'Virtual chassis position',
-            'vc_priority': 'Virtual chassis priority',
-        }
-
-    def __init__(self, data=None, *args, **kwargs):
-        super().__init__(data, *args, **kwargs)
-
-        if data:
-
-            # Limit device type queryset by manufacturer
-            params = {f"manufacturer__{self.fields['manufacturer'].to_field_name}": data.get('manufacturer')}
-            self.fields['device_type'].queryset = self.fields['device_type'].queryset.filter(**params)
-
-
-class DeviceCSVForm(BaseDeviceCSVForm):
-    site = CSVModelChoiceField(
-        queryset=Site.objects.all(),
-        to_field_name='name',
-        help_text='Assigned site'
-    )
-    location = CSVModelChoiceField(
-        queryset=Location.objects.all(),
-        to_field_name='name',
-        required=False,
-        help_text="Assigned location (if any)"
-    )
-    rack = CSVModelChoiceField(
-        queryset=Rack.objects.all(),
-        to_field_name='name',
-        required=False,
-        help_text="Assigned rack (if any)"
-    )
-    face = CSVChoiceField(
-        choices=DeviceFaceChoices,
-        required=False,
-        help_text='Mounted rack face'
-    )
-
-    class Meta(BaseDeviceCSVForm.Meta):
-        fields = [
-            'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status',
-            'site', 'location', 'rack', 'position', 'face', 'virtual_chassis', 'vc_position', 'vc_priority', 'cluster',
-            'comments',
-        ]
-
-    def __init__(self, data=None, *args, **kwargs):
-        super().__init__(data, *args, **kwargs)
-
-        if data:
-
-            # Limit location queryset by assigned site
-            params = {f"site__{self.fields['site'].to_field_name}": data.get('site')}
-            self.fields['location'].queryset = self.fields['location'].queryset.filter(**params)
-
-            # Limit rack queryset by assigned site and group
-            params = {
-                f"site__{self.fields['site'].to_field_name}": data.get('site'),
-                f"location__{self.fields['location'].to_field_name}": data.get('location'),
-            }
-            self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params)
-
-
-class ChildDeviceCSVForm(BaseDeviceCSVForm):
-    parent = CSVModelChoiceField(
-        queryset=Device.objects.all(),
-        to_field_name='name',
-        help_text='Parent device'
-    )
-    device_bay = CSVModelChoiceField(
-        queryset=DeviceBay.objects.all(),
-        to_field_name='name',
-        help_text='Device bay in which this device is installed'
-    )
-
-    class Meta(BaseDeviceCSVForm.Meta):
-        fields = [
-            'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status',
-            'parent', 'device_bay', 'virtual_chassis', 'vc_position', 'vc_priority', 'cluster', 'comments',
-        ]
-
-    def __init__(self, data=None, *args, **kwargs):
-        super().__init__(data, *args, **kwargs)
-
-        if data:
-
-            # Limit device bay queryset by parent device
-            params = {f"device__{self.fields['parent'].to_field_name}": data.get('parent')}
-            self.fields['device_bay'].queryset = self.fields['device_bay'].queryset.filter(**params)
-
-    def clean(self):
-        super().clean()
-
-        # Set parent_bay reverse relationship
-        device_bay = self.cleaned_data.get('device_bay')
-        if device_bay:
-            self.instance.parent_bay = device_bay
-
-        # Inherit site and rack from parent device
-        parent = self.cleaned_data.get('parent')
-        if parent:
-            self.instance.site = parent.site
-            self.instance.rack = parent.rack
-
-
-class DeviceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=Device.objects.all(),
-        widget=forms.MultipleHiddenInput()
-    )
-    manufacturer = DynamicModelChoiceField(
-        queryset=Manufacturer.objects.all(),
-        required=False
-    )
-    device_type = DynamicModelChoiceField(
-        queryset=DeviceType.objects.all(),
-        required=False,
-        query_params={
-            'manufacturer_id': '$manufacturer'
-        }
-    )
-    device_role = DynamicModelChoiceField(
-        queryset=DeviceRole.objects.all(),
-        required=False
-    )
-    site = DynamicModelChoiceField(
-        queryset=Site.objects.all(),
-        required=False
-    )
-    location = DynamicModelChoiceField(
-        queryset=Location.objects.all(),
-        required=False,
-        query_params={
-            'site_id': '$site'
-        }
-    )
-    tenant = DynamicModelChoiceField(
-        queryset=Tenant.objects.all(),
-        required=False
-    )
-    platform = DynamicModelChoiceField(
-        queryset=Platform.objects.all(),
-        required=False
-    )
-    status = forms.ChoiceField(
-        choices=add_blank_choice(DeviceStatusChoices),
-        required=False,
-        widget=StaticSelect()
-    )
-    serial = forms.CharField(
-        max_length=50,
-        required=False,
-        label='Serial Number'
-    )
-
-    class Meta:
-        nullable_fields = [
-            'tenant', 'platform', 'serial',
-        ]
-
-
-class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilterForm, CustomFieldModelFilterForm):
-    model = Device
-    field_order = [
-        'q', 'region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'status', 'role_id', 'tenant_group_id',
-        'tenant_id', 'manufacturer_id', 'device_type_id', 'asset_tag', 'mac_address', 'has_primary_ip',
-    ]
-    field_groups = [
-        ['q', 'tag'],
-        ['region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id'],
-        ['status', 'role_id', 'serial', 'asset_tag', 'mac_address'],
-        ['manufacturer_id', 'device_type_id', 'platform_id'],
-        ['tenant_group_id', 'tenant_id'],
-        [
-            'has_primary_ip', 'virtual_chassis_member', 'console_ports', 'console_server_ports', 'power_ports',
-            'power_outlets', 'interfaces', 'pass_through_ports', 'local_context_data',
-        ],
-    ]
-    q = forms.CharField(
-        required=False,
-        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
-        label=_('Search')
-    )
-    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,
-        query_params={
-            'region_id': '$region_id',
-            'group_id': '$site_group_id',
-        },
-        label=_('Site'),
-        fetch_trigger='open'
-    )
-    location_id = DynamicModelMultipleChoiceField(
-        queryset=Location.objects.all(),
-        required=False,
-        null_option='None',
-        query_params={
-            'site_id': '$site_id'
-        },
-        label=_('Location'),
-        fetch_trigger='open'
-    )
-    rack_id = DynamicModelMultipleChoiceField(
-        queryset=Rack.objects.all(),
-        required=False,
-        null_option='None',
-        query_params={
-            'site_id': '$site_id',
-            'location_id': '$location_id',
-        },
-        label=_('Rack'),
-        fetch_trigger='open'
-    )
-    role_id = DynamicModelMultipleChoiceField(
-        queryset=DeviceRole.objects.all(),
-        required=False,
-        label=_('Role'),
-        fetch_trigger='open'
-    )
-    manufacturer_id = DynamicModelMultipleChoiceField(
-        queryset=Manufacturer.objects.all(),
-        required=False,
-        label=_('Manufacturer'),
-        fetch_trigger='open'
-    )
-    device_type_id = DynamicModelMultipleChoiceField(
-        queryset=DeviceType.objects.all(),
-        required=False,
-        query_params={
-            'manufacturer_id': '$manufacturer_id'
-        },
-        label=_('Model'),
-        fetch_trigger='open'
-    )
-    platform_id = DynamicModelMultipleChoiceField(
-        queryset=Platform.objects.all(),
-        required=False,
-        null_option='None',
-        label=_('Platform'),
-        fetch_trigger='open'
-    )
-    status = forms.MultipleChoiceField(
-        choices=DeviceStatusChoices,
-        required=False,
-        widget=StaticSelectMultiple()
-    )
-    serial = forms.CharField(
-        required=False
-    )
-    asset_tag = forms.CharField(
-        required=False
-    )
-    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
-        )
-    )
-    virtual_chassis_member = forms.NullBooleanField(
-        required=False,
-        label='Virtual chassis member',
-        widget=StaticSelect(
-            choices=BOOLEAN_WITH_BLANK_CHOICES
-        )
-    )
-    console_ports = forms.NullBooleanField(
-        required=False,
-        label='Has console ports',
-        widget=StaticSelect(
-            choices=BOOLEAN_WITH_BLANK_CHOICES
-        )
-    )
-    console_server_ports = forms.NullBooleanField(
-        required=False,
-        label='Has console server ports',
-        widget=StaticSelect(
-            choices=BOOLEAN_WITH_BLANK_CHOICES
-        )
-    )
-    power_ports = forms.NullBooleanField(
-        required=False,
-        label='Has power ports',
-        widget=StaticSelect(
-            choices=BOOLEAN_WITH_BLANK_CHOICES
-        )
-    )
-    power_outlets = forms.NullBooleanField(
-        required=False,
-        label='Has power outlets',
-        widget=StaticSelect(
-            choices=BOOLEAN_WITH_BLANK_CHOICES
-        )
-    )
-    interfaces = forms.NullBooleanField(
-        required=False,
-        label='Has interfaces',
-        widget=StaticSelect(
-            choices=BOOLEAN_WITH_BLANK_CHOICES
-        )
-    )
-    pass_through_ports = forms.NullBooleanField(
-        required=False,
-        label='Has pass-through ports',
-        widget=StaticSelect(
-            choices=BOOLEAN_WITH_BLANK_CHOICES
-        )
-    )
-    tag = TagFilterField(model)
-
-
-#
-# Device components
-#
-
-class ComponentCreateForm(BootstrapMixin, CustomFieldsMixin, ComponentForm):
-    """
-    Base form for the creation of device components (models subclassed from ComponentModel).
-    """
-    device = DynamicModelChoiceField(
-        queryset=Device.objects.all()
-    )
-    description = forms.CharField(
-        max_length=200,
-        required=False
-    )
-    tags = DynamicModelMultipleChoiceField(
-        queryset=Tag.objects.all(),
-        required=False
-    )
-
-
-class DeviceBulkAddComponentForm(BootstrapMixin, CustomFieldsMixin, ComponentForm):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=Device.objects.all(),
-        widget=forms.MultipleHiddenInput()
-    )
-    description = forms.CharField(
-        max_length=100,
-        required=False
-    )
-    tags = DynamicModelMultipleChoiceField(
-        queryset=Tag.objects.all(),
-        required=False
-    )
-
-
-#
-# Console ports
-#
-
-
-class ConsolePortFilterForm(DeviceComponentFilterForm):
-    model = ConsolePort
-    field_groups = [
-        ['q', 'tag'],
-        ['name', 'label', 'type', 'speed'],
-        ['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
-    ]
-    type = forms.MultipleChoiceField(
-        choices=ConsolePortTypeChoices,
-        required=False,
-        widget=StaticSelectMultiple()
-    )
-    speed = forms.MultipleChoiceField(
-        choices=ConsolePortSpeedChoices,
-        required=False,
-        widget=StaticSelectMultiple()
-    )
-    tag = TagFilterField(model)
-
-
-class ConsolePortForm(BootstrapMixin, CustomFieldModelForm):
-    tags = DynamicModelMultipleChoiceField(
-        queryset=Tag.objects.all(),
-        required=False
-    )
-
-    class Meta:
-        model = ConsolePort
-        fields = [
-            'device', 'name', 'label', 'type', 'speed', 'mark_connected', 'description', 'tags',
-        ]
-        widgets = {
-            'device': forms.HiddenInput(),
-        }
-
-
-class ConsolePortCreateForm(ComponentCreateForm):
-    model = ConsolePort
-    type = forms.ChoiceField(
-        choices=add_blank_choice(ConsolePortTypeChoices),
-        required=False,
-        widget=StaticSelect()
-    )
-    speed = forms.ChoiceField(
-        choices=add_blank_choice(ConsolePortSpeedChoices),
-        required=False,
-        widget=StaticSelect()
-    )
-    field_order = ('device', 'name_pattern', 'label_pattern', 'type', 'speed', 'mark_connected', 'description', 'tags')
-
-
-class ConsolePortBulkCreateForm(
-    form_from_model(ConsolePort, ['type', 'speed', 'mark_connected']),
-    DeviceBulkAddComponentForm
-):
-    model = ConsolePort
-    field_order = ('name_pattern', 'label_pattern', 'type', 'mark_connected', 'description', 'tags')
-
-
-class ConsolePortBulkEditForm(
-    form_from_model(ConsolePort, ['label', 'type', 'speed', 'mark_connected', 'description']),
-    BootstrapMixin,
-    AddRemoveTagsForm,
-    CustomFieldModelBulkEditForm
-):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=ConsolePort.objects.all(),
-        widget=forms.MultipleHiddenInput()
-    )
-    mark_connected = forms.NullBooleanField(
-        required=False,
-        widget=BulkEditNullBooleanSelect
-    )
-
-    class Meta:
-        nullable_fields = ['label', 'description']
-
-
-class ConsolePortCSVForm(CustomFieldModelCSVForm):
-    device = CSVModelChoiceField(
-        queryset=Device.objects.all(),
-        to_field_name='name'
-    )
-    type = CSVChoiceField(
-        choices=ConsolePortTypeChoices,
-        required=False,
-        help_text='Port type'
-    )
-    speed = CSVTypedChoiceField(
-        choices=ConsolePortSpeedChoices,
-        coerce=int,
-        empty_value=None,
-        required=False,
-        help_text='Port speed in bps'
-    )
-
-    class Meta:
-        model = ConsolePort
-        fields = ('device', 'name', 'label', 'type', 'speed', 'mark_connected', 'description')
-
-
-#
-# Console server ports
-#
-
-
-class ConsoleServerPortFilterForm(DeviceComponentFilterForm):
-    model = ConsoleServerPort
-    field_groups = [
-        ['q', 'tag'],
-        ['name', 'label', 'type', 'speed'],
-        ['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
-    ]
-    type = forms.MultipleChoiceField(
-        choices=ConsolePortTypeChoices,
-        required=False,
-        widget=StaticSelectMultiple()
-    )
-    speed = forms.MultipleChoiceField(
-        choices=ConsolePortSpeedChoices,
-        required=False,
-        widget=StaticSelectMultiple()
-    )
-    tag = TagFilterField(model)
-
-
-class ConsoleServerPortForm(BootstrapMixin, CustomFieldModelForm):
-    tags = DynamicModelMultipleChoiceField(
-        queryset=Tag.objects.all(),
-        required=False
-    )
-
-    class Meta:
-        model = ConsoleServerPort
-        fields = [
-            'device', 'name', 'label', 'type', 'speed', 'mark_connected', 'description', 'tags',
-        ]
-        widgets = {
-            'device': forms.HiddenInput(),
-        }
-
-
-class ConsoleServerPortCreateForm(ComponentCreateForm):
-    model = ConsoleServerPort
-    type = forms.ChoiceField(
-        choices=add_blank_choice(ConsolePortTypeChoices),
-        required=False,
-        widget=StaticSelect()
-    )
-    speed = forms.ChoiceField(
-        choices=add_blank_choice(ConsolePortSpeedChoices),
-        required=False,
-        widget=StaticSelect()
-    )
-    field_order = ('device', 'name_pattern', 'label_pattern', 'type', 'speed', 'mark_connected', 'description', 'tags')
-
-
-class ConsoleServerPortBulkCreateForm(
-    form_from_model(ConsoleServerPort, ['type', 'speed', 'mark_connected']),
-    DeviceBulkAddComponentForm
-):
-    model = ConsoleServerPort
-    field_order = ('name_pattern', 'label_pattern', 'type', 'speed', 'description', 'tags')
-
-
-class ConsoleServerPortBulkEditForm(
-    form_from_model(ConsoleServerPort, ['label', 'type', 'speed', 'mark_connected', 'description']),
-    BootstrapMixin,
-    AddRemoveTagsForm,
-    CustomFieldModelBulkEditForm
-):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=ConsoleServerPort.objects.all(),
-        widget=forms.MultipleHiddenInput()
-    )
-    mark_connected = forms.NullBooleanField(
-        required=False,
-        widget=BulkEditNullBooleanSelect
-    )
-
-    class Meta:
-        nullable_fields = ['label', 'description']
-
-
-class ConsoleServerPortCSVForm(CustomFieldModelCSVForm):
-    device = CSVModelChoiceField(
-        queryset=Device.objects.all(),
-        to_field_name='name'
-    )
-    type = CSVChoiceField(
-        choices=ConsolePortTypeChoices,
-        required=False,
-        help_text='Port type'
-    )
-    speed = CSVTypedChoiceField(
-        choices=ConsolePortSpeedChoices,
-        coerce=int,
-        empty_value=None,
-        required=False,
-        help_text='Port speed in bps'
-    )
-
-    class Meta:
-        model = ConsoleServerPort
-        fields = ('device', 'name', 'label', 'type', 'speed', 'mark_connected', 'description')
-
-
-#
-# Power ports
-#
-
-
-class PowerPortFilterForm(DeviceComponentFilterForm):
-    model = PowerPort
-    field_groups = [
-        ['q', 'tag'],
-        ['name', 'label', 'type'],
-        ['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
-    ]
-    type = forms.MultipleChoiceField(
-        choices=PowerPortTypeChoices,
-        required=False,
-        widget=StaticSelectMultiple()
-    )
-    tag = TagFilterField(model)
-
-
-class PowerPortForm(BootstrapMixin, CustomFieldModelForm):
-    tags = DynamicModelMultipleChoiceField(
-        queryset=Tag.objects.all(),
-        required=False
-    )
-
-    class Meta:
-        model = PowerPort
-        fields = [
-            'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'mark_connected', 'description',
-            'tags',
-        ]
-        widgets = {
-            'device': forms.HiddenInput(),
-        }
-
-
-class PowerPortCreateForm(ComponentCreateForm):
-    model = PowerPort
-    type = forms.ChoiceField(
-        choices=add_blank_choice(PowerPortTypeChoices),
-        required=False,
-        widget=StaticSelect()
-    )
-    maximum_draw = forms.IntegerField(
-        min_value=1,
-        required=False,
-        help_text="Maximum draw in watts"
-    )
-    allocated_draw = forms.IntegerField(
-        min_value=1,
-        required=False,
-        help_text="Allocated draw in watts"
-    )
-    field_order = (
-        'device', 'name_pattern', 'label_pattern', 'type', 'maximum_draw', 'allocated_draw', 'mark_connected',
-        'description', 'tags',
-    )
-
-
-class PowerPortBulkCreateForm(
-    form_from_model(PowerPort, ['type', 'maximum_draw', 'allocated_draw', 'mark_connected']),
-    DeviceBulkAddComponentForm
-):
-    model = PowerPort
-    field_order = ('name_pattern', 'label_pattern', 'type', 'maximum_draw', 'allocated_draw', 'description', 'tags')
-
-
-class PowerPortBulkEditForm(
-    form_from_model(PowerPort, ['label', 'type', 'maximum_draw', 'allocated_draw', 'mark_connected', 'description']),
-    BootstrapMixin,
-    AddRemoveTagsForm,
-    CustomFieldModelBulkEditForm
-):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=PowerPort.objects.all(),
-        widget=forms.MultipleHiddenInput()
-    )
-    mark_connected = forms.NullBooleanField(
-        required=False,
-        widget=BulkEditNullBooleanSelect
-    )
-
-    class Meta:
-        nullable_fields = ['label', 'description']
-
-
-class PowerPortCSVForm(CustomFieldModelCSVForm):
-    device = CSVModelChoiceField(
-        queryset=Device.objects.all(),
-        to_field_name='name'
-    )
-    type = CSVChoiceField(
-        choices=PowerPortTypeChoices,
-        required=False,
-        help_text='Port type'
-    )
-
-    class Meta:
-        model = PowerPort
-        fields = (
-            'device', 'name', 'label', 'type', 'mark_connected', 'maximum_draw', 'allocated_draw', 'description',
-        )
-
-
-#
-# Power outlets
-#
-
-
-class PowerOutletFilterForm(DeviceComponentFilterForm):
-    model = PowerOutlet
-    field_groups = [
-        ['q', 'tag'],
-        ['name', 'label', 'type'],
-        ['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
-    ]
-    type = forms.MultipleChoiceField(
-        choices=PowerOutletTypeChoices,
-        required=False,
-        widget=StaticSelectMultiple()
-    )
-    tag = TagFilterField(model)
-
-
-class PowerOutletForm(BootstrapMixin, CustomFieldModelForm):
-    power_port = forms.ModelChoiceField(
-        queryset=PowerPort.objects.all(),
-        required=False
-    )
-    tags = DynamicModelMultipleChoiceField(
-        queryset=Tag.objects.all(),
-        required=False
-    )
-
-    class Meta:
-        model = PowerOutlet
-        fields = [
-            'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'mark_connected', 'description', 'tags',
-        ]
-        widgets = {
-            'device': forms.HiddenInput(),
-        }
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-
-        # Limit power_port choices to the local device
-        if hasattr(self.instance, 'device'):
-            self.fields['power_port'].queryset = PowerPort.objects.filter(
-                device=self.instance.device
-            )
-
-
-class PowerOutletCreateForm(ComponentCreateForm):
-    model = PowerOutlet
-    type = forms.ChoiceField(
-        choices=add_blank_choice(PowerOutletTypeChoices),
-        required=False,
-        widget=StaticSelect()
-    )
-    power_port = forms.ModelChoiceField(
-        queryset=PowerPort.objects.all(),
-        required=False
-    )
-    feed_leg = forms.ChoiceField(
-        choices=add_blank_choice(PowerOutletFeedLegChoices),
-        required=False
-    )
-    field_order = (
-        'device', 'name_pattern', 'label_pattern', 'type', 'power_port', 'feed_leg', 'mark_connected', 'description',
-        'tags',
-    )
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-
-        # Limit power_port queryset to PowerPorts which belong to the parent Device
-        device = Device.objects.get(
-            pk=self.initial.get('device') or self.data.get('device')
-        )
-        self.fields['power_port'].queryset = PowerPort.objects.filter(device=device)
-
-
-class PowerOutletBulkCreateForm(
-    form_from_model(PowerOutlet, ['type', 'feed_leg', 'mark_connected']),
-    DeviceBulkAddComponentForm
-):
-    model = PowerOutlet
-    field_order = ('name_pattern', 'label_pattern', 'type', 'feed_leg', 'description', 'tags')
-
-
-class PowerOutletBulkEditForm(
-    form_from_model(PowerOutlet, ['label', 'type', 'feed_leg', 'power_port', 'mark_connected', 'description']),
-    BootstrapMixin,
-    AddRemoveTagsForm,
-    CustomFieldModelBulkEditForm
-):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=PowerOutlet.objects.all(),
-        widget=forms.MultipleHiddenInput()
-    )
-    device = forms.ModelChoiceField(
-        queryset=Device.objects.all(),
-        required=False,
-        disabled=True,
-        widget=forms.HiddenInput()
-    )
-    mark_connected = forms.NullBooleanField(
-        required=False,
-        widget=BulkEditNullBooleanSelect
-    )
-
-    class Meta:
-        nullable_fields = ['label', 'type', 'feed_leg', 'power_port', 'description']
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-
-        # Limit power_port queryset to PowerPorts which belong to the parent Device
-        if 'device' in self.initial:
-            device = Device.objects.filter(pk=self.initial['device']).first()
-            self.fields['power_port'].queryset = PowerPort.objects.filter(device=device)
-        else:
-            self.fields['power_port'].choices = ()
-            self.fields['power_port'].widget.attrs['disabled'] = True
-
-
-class PowerOutletCSVForm(CustomFieldModelCSVForm):
-    device = CSVModelChoiceField(
-        queryset=Device.objects.all(),
-        to_field_name='name'
-    )
-    type = CSVChoiceField(
-        choices=PowerOutletTypeChoices,
-        required=False,
-        help_text='Outlet type'
-    )
-    power_port = CSVModelChoiceField(
-        queryset=PowerPort.objects.all(),
-        required=False,
-        to_field_name='name',
-        help_text='Local power port which feeds this outlet'
-    )
-    feed_leg = CSVChoiceField(
-        choices=PowerOutletFeedLegChoices,
-        required=False,
-        help_text='Electrical phase (for three-phase circuits)'
-    )
-
-    class Meta:
-        model = PowerOutlet
-        fields = ('device', 'name', 'label', 'type', 'mark_connected', 'power_port', 'feed_leg', 'description')
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-
-        # Limit PowerPort choices to those belonging to this device (or VC master)
-        if self.is_bound:
-            try:
-                device = self.fields['device'].to_python(self.data['device'])
-            except forms.ValidationError:
-                device = None
-        else:
-            try:
-                device = self.instance.device
-            except Device.DoesNotExist:
-                device = None
-
-        if device:
-            self.fields['power_port'].queryset = PowerPort.objects.filter(
-                device__in=[device, device.get_vc_master()]
-            )
-        else:
-            self.fields['power_port'].queryset = PowerPort.objects.none()
-
-
-#
-# Interfaces
-#
-
-
-class InterfaceFilterForm(DeviceComponentFilterForm):
-    model = Interface
-    field_groups = [
-        ['q', 'tag'],
-        ['name', 'label', 'type', 'enabled', 'mgmt_only', 'mac_address'],
-        ['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
-    ]
-    type = forms.MultipleChoiceField(
-        choices=InterfaceTypeChoices,
-        required=False,
-        widget=StaticSelectMultiple()
-    )
-    enabled = forms.NullBooleanField(
-        required=False,
-        widget=StaticSelect(
-            choices=BOOLEAN_WITH_BLANK_CHOICES
-        )
-    )
-    mgmt_only = forms.NullBooleanField(
-        required=False,
-        widget=StaticSelect(
-            choices=BOOLEAN_WITH_BLANK_CHOICES
-        )
-    )
-    mac_address = forms.CharField(
-        required=False,
-        label='MAC address'
-    )
-    tag = TagFilterField(model)
-
-
-class InterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm):
-    parent = DynamicModelChoiceField(
-        queryset=Interface.objects.all(),
-        required=False,
-        label='Parent interface'
-    )
-    lag = DynamicModelChoiceField(
-        queryset=Interface.objects.all(),
-        required=False,
-        label='LAG interface',
-        query_params={
-            'type': 'lag',
-        }
-    )
-    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 = Interface
-        fields = [
-            'device', 'name', 'label', 'type', 'enabled', 'parent', 'lag', 'mac_address', 'mtu', 'mgmt_only',
-            'mark_connected', 'description', 'mode', 'untagged_vlan', 'tagged_vlans', 'tags',
-        ]
-        widgets = {
-            'device': forms.HiddenInput(),
-            'type': StaticSelect(),
-            'mode': StaticSelect(),
-        }
-        labels = {
-            'mode': '802.1Q Mode',
-        }
-        help_texts = {
-            'mode': INTERFACE_MODE_HELP_TEXT,
-        }
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-
-        device = Device.objects.get(pk=self.data['device']) if self.is_bound else self.instance.device
-
-        # Restrict parent/LAG interface assignment by device/VC
-        self.fields['parent'].widget.add_query_param('device_id', device.pk)
-        if device.virtual_chassis and device.virtual_chassis.master:
-            # Get available LAG interfaces by VirtualChassis master
-            self.fields['lag'].widget.add_query_param('device_id', device.virtual_chassis.master.pk)
-        else:
-            self.fields['lag'].widget.add_query_param('device_id', device.pk)
-
-        # Limit VLAN choices by device
-        self.fields['untagged_vlan'].widget.add_query_param('available_on_device', device.pk)
-        self.fields['tagged_vlans'].widget.add_query_param('available_on_device', device.pk)
-
-
-class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm):
-    model = Interface
-    type = forms.ChoiceField(
-        choices=InterfaceTypeChoices,
-        widget=StaticSelect(),
-    )
-    enabled = forms.BooleanField(
-        required=False,
-        initial=True
-    )
-    parent = DynamicModelChoiceField(
-        queryset=Interface.objects.all(),
-        required=False,
-        query_params={
-            'device_id': '$device',
-        }
-    )
-    lag = DynamicModelChoiceField(
-        queryset=Interface.objects.all(),
-        required=False,
-        query_params={
-            'device_id': '$device',
-            'type': 'lag',
-        }
-    )
-    mac_address = forms.CharField(
-        required=False,
-        label='MAC Address'
-    )
-    mgmt_only = forms.BooleanField(
-        required=False,
-        label='Management only',
-        help_text='This interface is used only for out-of-band management'
-    )
-    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
-    )
-    field_order = (
-        'device', 'name_pattern', 'label_pattern', 'type', 'enabled', 'parent', 'lag', 'mtu', 'mac_address',
-        'description', 'mgmt_only', 'mark_connected', 'mode', 'untagged_vlan', 'tagged_vlans', 'tags'
-    )
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-
-        # Limit VLAN choices by device
-        device_id = self.initial.get('device') or self.data.get('device')
-        self.fields['untagged_vlan'].widget.add_query_param('available_on_device', device_id)
-        self.fields['tagged_vlans'].widget.add_query_param('available_on_device', device_id)
-
-
-class InterfaceBulkCreateForm(
-    form_from_model(Interface, ['type', 'enabled', 'mtu', 'mgmt_only', 'mark_connected']),
-    DeviceBulkAddComponentForm
-):
-    model = Interface
-    field_order = (
-        'name_pattern', 'label_pattern', 'type', 'enabled', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'tags',
-    )
-
-
-class InterfaceBulkEditForm(
-    form_from_model(Interface, [
-        'label', 'type', 'parent', 'lag', 'mac_address', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'mode',
-    ]),
-    BootstrapMixin,
-    AddRemoveTagsForm,
-    CustomFieldModelBulkEditForm
-):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=Interface.objects.all(),
-        widget=forms.MultipleHiddenInput()
-    )
-    device = forms.ModelChoiceField(
-        queryset=Device.objects.all(),
-        required=False,
-        disabled=True,
-        widget=forms.HiddenInput()
-    )
-    enabled = forms.NullBooleanField(
-        required=False,
-        widget=BulkEditNullBooleanSelect
-    )
-    parent = DynamicModelChoiceField(
-        queryset=Interface.objects.all(),
-        required=False
-    )
-    lag = DynamicModelChoiceField(
-        queryset=Interface.objects.all(),
-        required=False,
-        query_params={
-            'type': 'lag',
-        }
-    )
-    mgmt_only = forms.NullBooleanField(
-        required=False,
-        widget=BulkEditNullBooleanSelect,
-        label='Management only'
-    )
-    mark_connected = forms.NullBooleanField(
-        required=False,
-        widget=BulkEditNullBooleanSelect
-    )
-    untagged_vlan = DynamicModelChoiceField(
-        queryset=VLAN.objects.all(),
-        required=False
-    )
-    tagged_vlans = DynamicModelMultipleChoiceField(
-        queryset=VLAN.objects.all(),
-        required=False
-    )
-
-    class Meta:
-        nullable_fields = [
-            'label', 'parent', 'lag', 'mac_address', 'mtu', 'description', 'mode', 'untagged_vlan', 'tagged_vlans'
-        ]
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-        if 'device' in self.initial:
-            device = Device.objects.filter(pk=self.initial['device']).first()
-
-            # Restrict parent/LAG interface assignment by device
-            self.fields['parent'].widget.add_query_param('device_id', device.pk)
-            self.fields['lag'].widget.add_query_param('device_id', device.pk)
-
-            # Limit VLAN choices by device
-            self.fields['untagged_vlan'].widget.add_query_param('available_on_device', device.pk)
-            self.fields['tagged_vlans'].widget.add_query_param('available_on_device', device.pk)
-
-        else:
-            # See #4523
-            if 'pk' in self.initial:
-                site = None
-                interfaces = Interface.objects.filter(pk__in=self.initial['pk']).prefetch_related('device__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.device.site
-                    elif interface.device.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)
-
-            self.fields['parent'].choices = ()
-            self.fields['parent'].widget.attrs['disabled'] = True
-            self.fields['lag'].choices = ()
-            self.fields['lag'].widget.attrs['disabled'] = True
-
-    def clean(self):
-        super().clean()
-
-        # Untagged interfaces cannot be assigned tagged VLANs
-        if self.cleaned_data['mode'] == InterfaceModeChoices.MODE_ACCESS and self.cleaned_data['tagged_vlans']:
-            raise forms.ValidationError({
-                'mode': "An access interface cannot have tagged VLANs assigned."
-            })
-
-        # Remove all tagged VLAN assignments from "tagged all" interfaces
-        elif self.cleaned_data['mode'] == InterfaceModeChoices.MODE_TAGGED_ALL:
-            self.cleaned_data['tagged_vlans'] = []
-
-
-class InterfaceCSVForm(CustomFieldModelCSVForm):
-    device = CSVModelChoiceField(
-        queryset=Device.objects.all(),
-        to_field_name='name'
-    )
-    parent = CSVModelChoiceField(
-        queryset=Interface.objects.all(),
-        required=False,
-        to_field_name='name',
-        help_text='Parent interface'
-    )
-    lag = CSVModelChoiceField(
-        queryset=Interface.objects.all(),
-        required=False,
-        to_field_name='name',
-        help_text='Parent LAG interface'
-    )
-    type = CSVChoiceField(
-        choices=InterfaceTypeChoices,
-        help_text='Physical medium'
-    )
-    mode = CSVChoiceField(
-        choices=InterfaceModeChoices,
-        required=False,
-        help_text='IEEE 802.1Q operational mode (for L2 interfaces)'
-    )
-
-    class Meta:
-        model = Interface
-        fields = (
-            'device', 'name', 'label', 'parent', 'lag', 'type', 'enabled', 'mark_connected', 'mac_address', 'mtu',
-            'mgmt_only', 'description', 'mode',
-        )
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-
-        # Limit LAG choices to interfaces belonging to this device (or virtual chassis)
-        device = None
-        if self.is_bound and 'device' in self.data:
-            try:
-                device = self.fields['device'].to_python(self.data['device'])
-            except forms.ValidationError:
-                pass
-        if device and device.virtual_chassis:
-            self.fields['lag'].queryset = Interface.objects.filter(
-                Q(device=device) | Q(device__virtual_chassis=device.virtual_chassis),
-                type=InterfaceTypeChoices.TYPE_LAG
-            )
-            self.fields['parent'].queryset = Interface.objects.filter(
-                Q(device=device) | Q(device__virtual_chassis=device.virtual_chassis)
-            )
-        elif device:
-            self.fields['lag'].queryset = Interface.objects.filter(
-                device=device,
-                type=InterfaceTypeChoices.TYPE_LAG
-            )
-            self.fields['parent'].queryset = Interface.objects.filter(device=device)
-        else:
-            self.fields['lag'].queryset = Interface.objects.none()
-            self.fields['parent'].queryset = Interface.objects.none()
-
-    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']
-
-
-#
-# Front pass-through ports
-#
-
-class FrontPortFilterForm(DeviceComponentFilterForm):
-    field_groups = [
-        ['q', 'tag'],
-        ['name', 'label', 'type', 'color'],
-        ['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
-    ]
-    model = FrontPort
-    type = forms.MultipleChoiceField(
-        choices=PortTypeChoices,
-        required=False,
-        widget=StaticSelectMultiple()
-    )
-    color = ColorField(
-        required=False
-    )
-    tag = TagFilterField(model)
-
-
-class FrontPortForm(BootstrapMixin, CustomFieldModelForm):
-    tags = DynamicModelMultipleChoiceField(
-        queryset=Tag.objects.all(),
-        required=False
-    )
-
-    class Meta:
-        model = FrontPort
-        fields = [
-            'device', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'mark_connected',
-            'description', 'tags',
-        ]
-        widgets = {
-            'device': forms.HiddenInput(),
-            'type': StaticSelect(),
-            'rear_port': StaticSelect(),
-        }
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-
-        # Limit RearPort choices to the local device
-        if hasattr(self.instance, 'device'):
-            self.fields['rear_port'].queryset = self.fields['rear_port'].queryset.filter(
-                device=self.instance.device
-            )
-
-
-# TODO: Merge with FrontPortTemplateCreateForm to remove duplicate logic
-class FrontPortCreateForm(ComponentCreateForm):
-    model = FrontPort
-    type = forms.ChoiceField(
-        choices=PortTypeChoices,
-        widget=StaticSelect(),
-    )
-    color = ColorField(
-        required=False
-    )
-    rear_port_set = forms.MultipleChoiceField(
-        choices=[],
-        label='Rear ports',
-        help_text='Select one rear port assignment for each front port being created.',
-    )
-    field_order = (
-        'device', 'name_pattern', 'label_pattern', 'type', 'color', 'rear_port_set', 'mark_connected', 'description',
-        'tags',
-    )
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-
-        device = Device.objects.get(
-            pk=self.initial.get('device') or self.data.get('device')
-        )
-
-        # Determine which rear port positions are occupied. These will be excluded from the list of available
-        # mappings.
-        occupied_port_positions = [
-            (front_port.rear_port_id, front_port.rear_port_position)
-            for front_port in device.frontports.all()
-        ]
-
-        # Populate rear port choices
-        choices = []
-        rear_ports = RearPort.objects.filter(device=device)
-        for rear_port in rear_ports:
-            for i in range(1, rear_port.positions + 1):
-                if (rear_port.pk, i) not in occupied_port_positions:
-                    choices.append(
-                        ('{}:{}'.format(rear_port.pk, i), '{}:{}'.format(rear_port.name, i))
-                    )
-        self.fields['rear_port_set'].choices = choices
-
-    def clean(self):
-        super().clean()
-
-        # Validate that the number of ports being created equals the number of selected (rear port, position) tuples
-        front_port_count = len(self.cleaned_data['name_pattern'])
-        rear_port_count = len(self.cleaned_data['rear_port_set'])
-        if front_port_count != rear_port_count:
-            raise forms.ValidationError({
-                'rear_port_set': 'The provided name pattern will create {} ports, however {} rear port assignments '
-                                 'were selected. These counts must match.'.format(front_port_count, rear_port_count)
-            })
-
-    def get_iterative_data(self, iteration):
-
-        # Assign rear port and position from selected set
-        rear_port, position = self.cleaned_data['rear_port_set'][iteration].split(':')
-
-        return {
-            'rear_port': int(rear_port),
-            'rear_port_position': int(position),
-        }
-
-
-# class FrontPortBulkCreateForm(
-#     form_from_model(FrontPort, ['label', 'type', 'description', 'tags']),
-#     DeviceBulkAddComponentForm
-# ):
-#     pass
-
-
-class FrontPortBulkEditForm(
-    form_from_model(FrontPort, ['label', 'type', 'color', 'mark_connected', 'description']),
-    BootstrapMixin,
-    AddRemoveTagsForm,
-    CustomFieldModelBulkEditForm
-):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=FrontPort.objects.all(),
-        widget=forms.MultipleHiddenInput()
-    )
-
-    class Meta:
-        nullable_fields = ['label', 'description']
-
-
-class FrontPortCSVForm(CustomFieldModelCSVForm):
-    device = CSVModelChoiceField(
-        queryset=Device.objects.all(),
-        to_field_name='name'
-    )
-    rear_port = CSVModelChoiceField(
-        queryset=RearPort.objects.all(),
-        to_field_name='name',
-        help_text='Corresponding rear port'
-    )
-    type = CSVChoiceField(
-        choices=PortTypeChoices,
-        help_text='Physical medium classification'
-    )
-
-    class Meta:
-        model = FrontPort
-        fields = (
-            'device', 'name', 'label', 'type', 'color', 'mark_connected', 'rear_port', 'rear_port_position',
-            'description',
-        )
-        help_texts = {
-            'rear_port_position': 'Mapped position on corresponding rear port',
-        }
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-
-        # Limit RearPort choices to those belonging to this device (or VC master)
-        if self.is_bound:
-            try:
-                device = self.fields['device'].to_python(self.data['device'])
-            except forms.ValidationError:
-                device = None
-        else:
-            try:
-                device = self.instance.device
-            except Device.DoesNotExist:
-                device = None
-
-        if device:
-            self.fields['rear_port'].queryset = RearPort.objects.filter(
-                device__in=[device, device.get_vc_master()]
-            )
-        else:
-            self.fields['rear_port'].queryset = RearPort.objects.none()
-
-
-#
-# Rear pass-through ports
-#
-
-class RearPortFilterForm(DeviceComponentFilterForm):
-    model = RearPort
-    field_groups = [
-        ['q', 'tag'],
-        ['name', 'label', 'type', 'color'],
-        ['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
-    ]
-    type = forms.MultipleChoiceField(
-        choices=PortTypeChoices,
-        required=False,
-        widget=StaticSelectMultiple()
-    )
-    color = ColorField(
-        required=False
-    )
-    tag = TagFilterField(model)
-
-
-class RearPortForm(BootstrapMixin, CustomFieldModelForm):
-    tags = DynamicModelMultipleChoiceField(
-        queryset=Tag.objects.all(),
-        required=False
-    )
-
-    class Meta:
-        model = RearPort
-        fields = [
-            'device', 'name', 'label', 'type', 'color', 'positions', 'mark_connected', 'description', 'tags',
-        ]
-        widgets = {
-            'device': forms.HiddenInput(),
-            'type': StaticSelect(),
-        }
-
-
-class RearPortCreateForm(ComponentCreateForm):
-    model = RearPort
-    type = forms.ChoiceField(
-        choices=PortTypeChoices,
-        widget=StaticSelect(),
-    )
-    color = ColorField(
-        required=False
-    )
-    positions = forms.IntegerField(
-        min_value=REARPORT_POSITIONS_MIN,
-        max_value=REARPORT_POSITIONS_MAX,
-        initial=1,
-        help_text='The number of front ports which may be mapped to each rear port'
-    )
-    field_order = (
-        'device', 'name_pattern', 'label_pattern', 'type', 'color', 'positions', 'mark_connected', 'description',
-        'tags',
-    )
-
-
-class RearPortBulkCreateForm(
-    form_from_model(RearPort, ['type', 'color', 'positions', 'mark_connected']),
-    DeviceBulkAddComponentForm
-):
-    model = RearPort
-    field_order = ('name_pattern', 'label_pattern', 'type', 'positions', 'mark_connected', 'description', 'tags')
-
-
-class RearPortBulkEditForm(
-    form_from_model(RearPort, ['label', 'type', 'color', 'mark_connected', 'description']),
-    BootstrapMixin,
-    AddRemoveTagsForm,
-    CustomFieldModelBulkEditForm
-):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=RearPort.objects.all(),
-        widget=forms.MultipleHiddenInput()
-    )
-
-    class Meta:
-        nullable_fields = ['label', 'description']
-
-
-class RearPortCSVForm(CustomFieldModelCSVForm):
-    device = CSVModelChoiceField(
-        queryset=Device.objects.all(),
-        to_field_name='name'
-    )
-    type = CSVChoiceField(
-        help_text='Physical medium classification',
-        choices=PortTypeChoices,
-    )
-
-    class Meta:
-        model = RearPort
-        fields = ('device', 'name', 'label', 'type', 'color', 'mark_connected', 'positions', 'description')
-        help_texts = {
-            'positions': 'Number of front ports which may be mapped'
-        }
-
-
-#
-# Device bays
-#
-
-class DeviceBayFilterForm(DeviceComponentFilterForm):
-    model = DeviceBay
-    field_groups = [
-        ['q', 'tag'],
-        ['name', 'label'],
-        ['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
-    ]
-    tag = TagFilterField(model)
-
-
-class DeviceBayForm(BootstrapMixin, CustomFieldModelForm):
-    tags = DynamicModelMultipleChoiceField(
-        queryset=Tag.objects.all(),
-        required=False
-    )
-
-    class Meta:
-        model = DeviceBay
-        fields = [
-            'device', 'name', 'label', 'description', 'tags',
-        ]
-        widgets = {
-            'device': forms.HiddenInput(),
-        }
-
-
-class DeviceBayCreateForm(ComponentCreateForm):
-    model = DeviceBay
-    field_order = ('device', 'name_pattern', 'label_pattern', 'description', 'tags')
-
-
-class PopulateDeviceBayForm(BootstrapMixin, forms.Form):
-    installed_device = forms.ModelChoiceField(
-        queryset=Device.objects.all(),
-        label='Child Device',
-        help_text="Child devices must first be created and assigned to the site/rack of the parent device.",
-        widget=StaticSelect(),
-    )
-
-    def __init__(self, device_bay, *args, **kwargs):
-
-        super().__init__(*args, **kwargs)
-
-        self.fields['installed_device'].queryset = Device.objects.filter(
-            site=device_bay.device.site,
-            rack=device_bay.device.rack,
-            parent_bay__isnull=True,
-            device_type__u_height=0,
-            device_type__subdevice_role=SubdeviceRoleChoices.ROLE_CHILD
-        ).exclude(pk=device_bay.device.pk)
-
-
-class DeviceBayBulkCreateForm(DeviceBulkAddComponentForm):
-    model = DeviceBay
-    field_order = ('name_pattern', 'label_pattern', 'description', 'tags')
-
-
-class DeviceBayBulkEditForm(
-    form_from_model(DeviceBay, ['label', 'description']),
-    BootstrapMixin,
-    AddRemoveTagsForm,
-    CustomFieldModelBulkEditForm
-):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=DeviceBay.objects.all(),
-        widget=forms.MultipleHiddenInput()
-    )
-
-    class Meta:
-        nullable_fields = ['label', 'description']
-
-
-class DeviceBayCSVForm(CustomFieldModelCSVForm):
-    device = CSVModelChoiceField(
-        queryset=Device.objects.all(),
-        to_field_name='name'
-    )
-    installed_device = CSVModelChoiceField(
-        queryset=Device.objects.all(),
-        required=False,
-        to_field_name='name',
-        help_text='Child device installed within this bay',
-        error_messages={
-            'invalid_choice': 'Child device not found.',
-        }
-    )
-
-    class Meta:
-        model = DeviceBay
-        fields = ('device', 'name', 'label', 'installed_device', 'description')
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-
-        # Limit installed device choices to devices of the correct type and location
-        if self.is_bound:
-            try:
-                device = self.fields['device'].to_python(self.data['device'])
-            except forms.ValidationError:
-                device = None
-        else:
-            try:
-                device = self.instance.device
-            except Device.DoesNotExist:
-                device = None
-
-        if device:
-            self.fields['installed_device'].queryset = Device.objects.filter(
-                site=device.site,
-                rack=device.rack,
-                parent_bay__isnull=True,
-                device_type__u_height=0,
-                device_type__subdevice_role=SubdeviceRoleChoices.ROLE_CHILD
-            ).exclude(pk=device.pk)
-        else:
-            self.fields['installed_device'].queryset = Interface.objects.none()
-
-
-#
-# Inventory items
-#
-
-class InventoryItemForm(BootstrapMixin, CustomFieldModelForm):
-    device = DynamicModelChoiceField(
-        queryset=Device.objects.all()
-    )
-    parent = DynamicModelChoiceField(
-        queryset=InventoryItem.objects.all(),
-        required=False,
-        query_params={
-            'device_id': '$device'
-        }
-    )
-    manufacturer = DynamicModelChoiceField(
-        queryset=Manufacturer.objects.all(),
-        required=False
-    )
-    tags = DynamicModelMultipleChoiceField(
-        queryset=Tag.objects.all(),
-        required=False
-    )
-
-    class Meta:
-        model = InventoryItem
-        fields = [
-            'device', 'parent', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description',
-            'tags',
-        ]
-
-
-class InventoryItemCreateForm(ComponentCreateForm):
-    model = InventoryItem
-    manufacturer = DynamicModelChoiceField(
-        queryset=Manufacturer.objects.all(),
-        required=False
-    )
-    parent = DynamicModelChoiceField(
-        queryset=InventoryItem.objects.all(),
-        required=False,
-        query_params={
-            'device_id': '$device'
-        }
-    )
-    part_id = forms.CharField(
-        max_length=50,
-        required=False,
-        label='Part ID'
-    )
-    serial = forms.CharField(
-        max_length=50,
-        required=False,
-    )
-    asset_tag = forms.CharField(
-        max_length=50,
-        required=False,
-    )
-    field_order = (
-        'device', 'parent', 'name_pattern', 'label_pattern', 'manufacturer', 'part_id', 'serial', 'asset_tag',
-        'description', 'tags',
-    )
-
-
-class InventoryItemCSVForm(CustomFieldModelCSVForm):
-    device = CSVModelChoiceField(
-        queryset=Device.objects.all(),
-        to_field_name='name'
-    )
-    manufacturer = CSVModelChoiceField(
-        queryset=Manufacturer.objects.all(),
-        to_field_name='name',
-        required=False
-    )
-    parent = CSVModelChoiceField(
-        queryset=Device.objects.all(),
-        to_field_name='name',
-        required=False,
-        help_text='Parent inventory item'
-    )
-
-    class Meta:
-        model = InventoryItem
-        fields = (
-            'device', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', 'description',
-        )
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-
-        # Limit parent choices to inventory items belonging to this device
-        device = None
-        if self.is_bound and 'device' in self.data:
-            try:
-                device = self.fields['device'].to_python(self.data['device'])
-            except forms.ValidationError:
-                pass
-        if device:
-            self.fields['parent'].queryset = InventoryItem.objects.filter(device=device)
-        else:
-            self.fields['parent'].queryset = InventoryItem.objects.none()
-
-
-class InventoryItemBulkCreateForm(
-    form_from_model(InventoryItem, ['manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered']),
-    DeviceBulkAddComponentForm
-):
-    model = InventoryItem
-    field_order = (
-        'name_pattern', 'label_pattern', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', 'description',
-        'tags',
-    )
-
-
-class InventoryItemBulkEditForm(
-    form_from_model(InventoryItem, ['label', 'manufacturer', 'part_id', 'description']),
-    BootstrapMixin,
-    AddRemoveTagsForm,
-    CustomFieldModelBulkEditForm
-):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=InventoryItem.objects.all(),
-        widget=forms.MultipleHiddenInput()
-    )
-    manufacturer = DynamicModelChoiceField(
-        queryset=Manufacturer.objects.all(),
-        required=False
-    )
-
-    class Meta:
-        nullable_fields = ['label', 'manufacturer', 'part_id', 'description']
-
-
-class InventoryItemFilterForm(DeviceComponentFilterForm):
-    model = InventoryItem
-    field_groups = [
-        ['q', 'tag'],
-        ['name', 'label', 'manufacturer_id', 'serial', 'asset_tag', 'discovered'],
-        ['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
-    ]
-    manufacturer_id = DynamicModelMultipleChoiceField(
-        queryset=Manufacturer.objects.all(),
-        required=False,
-        label=_('Manufacturer'),
-        fetch_trigger='open'
-    )
-    serial = forms.CharField(
-        required=False
-    )
-    asset_tag = forms.CharField(
-        required=False
-    )
-    discovered = forms.NullBooleanField(
-        required=False,
-        widget=StaticSelect(
-            choices=BOOLEAN_WITH_BLANK_CHOICES
-        )
-    )
-    tag = TagFilterField(model)
-
-
-#
-# Cables
-#
-
-class ConnectCableToDeviceForm(BootstrapMixin, CustomFieldModelForm):
-    """
-    Base form for connecting a Cable to a Device component
-    """
-    termination_b_region = DynamicModelChoiceField(
-        queryset=Region.objects.all(),
-        label='Region',
-        required=False
-    )
-    termination_b_site_group = DynamicModelChoiceField(
-        queryset=SiteGroup.objects.all(),
-        label='Site group',
-        required=False
-    )
-    termination_b_site = DynamicModelChoiceField(
-        queryset=Site.objects.all(),
-        label='Site',
-        required=False,
-        query_params={
-            'region_id': '$termination_b_region',
-            'group_id': '$termination_b_site_group',
-        }
-    )
-    termination_b_location = DynamicModelChoiceField(
-        queryset=Location.objects.all(),
-        label='Location',
-        required=False,
-        null_option='None',
-        query_params={
-            'site_id': '$termination_b_site'
-        }
-    )
-    termination_b_rack = DynamicModelChoiceField(
-        queryset=Rack.objects.all(),
-        label='Rack',
-        required=False,
-        null_option='None',
-        query_params={
-            'site_id': '$termination_b_site',
-            'location_id': '$termination_b_location',
-        }
-    )
-    termination_b_device = DynamicModelChoiceField(
-        queryset=Device.objects.all(),
-        label='Device',
-        required=False,
-        query_params={
-            'site_id': '$termination_b_site',
-            'location_id': '$termination_b_location',
-            'rack_id': '$termination_b_rack',
-        }
-    )
-    tags = DynamicModelMultipleChoiceField(
-        queryset=Tag.objects.all(),
-        required=False
-    )
-
-    class Meta:
-        model = Cable
-        fields = [
-            'termination_b_region', 'termination_b_site', 'termination_b_rack', 'termination_b_device',
-            'termination_b_id', 'type', 'status', 'label', 'color', 'length', 'length_unit', 'tags',
-        ]
-        widgets = {
-            'status': StaticSelect,
-            'type': StaticSelect,
-            'length_unit': StaticSelect,
-        }
-
-    def clean_termination_b_id(self):
-        # Return the PK rather than the object
-        return getattr(self.cleaned_data['termination_b_id'], 'pk', None)
-
-
-class ConnectCableToConsolePortForm(ConnectCableToDeviceForm):
-    termination_b_id = DynamicModelChoiceField(
-        queryset=ConsolePort.objects.all(),
-        label='Name',
-        disabled_indicator='_occupied',
-        query_params={
-            'device_id': '$termination_b_device'
-        }
-    )
-
-
-class ConnectCableToConsoleServerPortForm(ConnectCableToDeviceForm):
-    termination_b_id = DynamicModelChoiceField(
-        queryset=ConsoleServerPort.objects.all(),
-        label='Name',
-        disabled_indicator='_occupied',
-        query_params={
-            'device_id': '$termination_b_device'
-        }
-    )
-
-
-class ConnectCableToPowerPortForm(ConnectCableToDeviceForm):
-    termination_b_id = DynamicModelChoiceField(
-        queryset=PowerPort.objects.all(),
-        label='Name',
-        disabled_indicator='_occupied',
-        query_params={
-            'device_id': '$termination_b_device'
-        }
-    )
-
-
-class ConnectCableToPowerOutletForm(ConnectCableToDeviceForm):
-    termination_b_id = DynamicModelChoiceField(
-        queryset=PowerOutlet.objects.all(),
-        label='Name',
-        disabled_indicator='_occupied',
-        query_params={
-            'device_id': '$termination_b_device'
-        }
-    )
-
-
-class ConnectCableToInterfaceForm(ConnectCableToDeviceForm):
-    termination_b_id = DynamicModelChoiceField(
-        queryset=Interface.objects.all(),
-        label='Name',
-        disabled_indicator='_occupied',
-        query_params={
-            'device_id': '$termination_b_device',
-            'kind': 'physical',
-        }
-    )
-
-
-class ConnectCableToFrontPortForm(ConnectCableToDeviceForm):
-    termination_b_id = DynamicModelChoiceField(
-        queryset=FrontPort.objects.all(),
-        label='Name',
-        disabled_indicator='_occupied',
-        query_params={
-            'device_id': '$termination_b_device'
-        }
-    )
-
-
-class ConnectCableToRearPortForm(ConnectCableToDeviceForm):
-    termination_b_id = DynamicModelChoiceField(
-        queryset=RearPort.objects.all(),
-        label='Name',
-        disabled_indicator='_occupied',
-        query_params={
-            'device_id': '$termination_b_device'
-        }
-    )
-
-
-class ConnectCableToCircuitTerminationForm(BootstrapMixin, CustomFieldModelForm):
-    termination_b_provider = DynamicModelChoiceField(
-        queryset=Provider.objects.all(),
-        label='Provider',
-        required=False
-    )
-    termination_b_region = DynamicModelChoiceField(
-        queryset=Region.objects.all(),
-        label='Region',
-        required=False
-    )
-    termination_b_site_group = DynamicModelChoiceField(
-        queryset=SiteGroup.objects.all(),
-        label='Site group',
-        required=False
-    )
-    termination_b_site = DynamicModelChoiceField(
-        queryset=Site.objects.all(),
-        label='Site',
-        required=False,
-        query_params={
-            'region_id': '$termination_b_region',
-            'group_id': '$termination_b_site_group',
-        }
-    )
-    termination_b_circuit = DynamicModelChoiceField(
-        queryset=Circuit.objects.all(),
-        label='Circuit',
-        query_params={
-            'provider_id': '$termination_b_provider',
-            'site_id': '$termination_b_site',
-        }
-    )
-    termination_b_id = DynamicModelChoiceField(
-        queryset=CircuitTermination.objects.all(),
-        label='Side',
-        disabled_indicator='_occupied',
-        query_params={
-            'circuit_id': '$termination_b_circuit'
-        }
-    )
-    tags = DynamicModelMultipleChoiceField(
-        queryset=Tag.objects.all(),
-        required=False
-    )
-
-    class Meta:
-        model = Cable
-        fields = [
-            'termination_b_provider', 'termination_b_region', 'termination_b_site', 'termination_b_circuit',
-            'termination_b_id', 'type', 'status', 'label', 'color', 'length', 'length_unit', 'tags',
-        ]
-
-    def clean_termination_b_id(self):
-        # Return the PK rather than the object
-        return getattr(self.cleaned_data['termination_b_id'], 'pk', None)
-
-
-class ConnectCableToPowerFeedForm(BootstrapMixin, CustomFieldModelForm):
-    termination_b_region = DynamicModelChoiceField(
-        queryset=Region.objects.all(),
-        label='Region',
-        required=False
-    )
-    termination_b_site_group = DynamicModelChoiceField(
-        queryset=SiteGroup.objects.all(),
-        label='Site group',
-        required=False
-    )
-    termination_b_site = DynamicModelChoiceField(
-        queryset=Site.objects.all(),
-        label='Site',
-        required=False,
-        query_params={
-            'region_id': '$termination_b_region',
-            'group_id': '$termination_b_site_group',
-        }
-    )
-    termination_b_location = DynamicModelChoiceField(
-        queryset=Location.objects.all(),
-        label='Location',
-        required=False,
-        query_params={
-            'site_id': '$termination_b_site'
-        }
-    )
-    termination_b_powerpanel = DynamicModelChoiceField(
-        queryset=PowerPanel.objects.all(),
-        label='Power Panel',
-        required=False,
-        query_params={
-            'site_id': '$termination_b_site',
-            'location_id': '$termination_b_location',
-        }
-    )
-    termination_b_id = DynamicModelChoiceField(
-        queryset=PowerFeed.objects.all(),
-        label='Name',
-        disabled_indicator='_occupied',
-        query_params={
-            'power_panel_id': '$termination_b_powerpanel'
-        }
-    )
-    tags = DynamicModelMultipleChoiceField(
-        queryset=Tag.objects.all(),
-        required=False
-    )
-
-    class Meta:
-        model = Cable
-        fields = [
-            'termination_b_location', 'termination_b_powerpanel', 'termination_b_id', 'type', 'status', 'label',
-            'color', 'length', 'length_unit', 'tags',
-        ]
-
-    def clean_termination_b_id(self):
-        # Return the PK rather than the object
-        return getattr(self.cleaned_data['termination_b_id'], 'pk', None)
-
-
-class CableForm(BootstrapMixin, CustomFieldModelForm):
-    tags = DynamicModelMultipleChoiceField(
-        queryset=Tag.objects.all(),
-        required=False
-    )
-
-    class Meta:
-        model = Cable
-        fields = [
-            'type', 'status', 'label', 'color', 'length', 'length_unit', 'tags',
-        ]
-        widgets = {
-            'status': StaticSelect,
-            'type': StaticSelect,
-            'length_unit': StaticSelect,
-        }
-        error_messages = {
-            'length': {
-                'max_value': 'Maximum length is 32767 (any unit)'
-            }
-        }
-
-
-class CableCSVForm(CustomFieldModelCSVForm):
-    # Termination A
-    side_a_device = CSVModelChoiceField(
-        queryset=Device.objects.all(),
-        to_field_name='name',
-        help_text='Side A device'
-    )
-    side_a_type = CSVContentTypeField(
-        queryset=ContentType.objects.all(),
-        limit_choices_to=CABLE_TERMINATION_MODELS,
-        help_text='Side A type'
-    )
-    side_a_name = forms.CharField(
-        help_text='Side A component name'
-    )
-
-    # Termination B
-    side_b_device = CSVModelChoiceField(
-        queryset=Device.objects.all(),
-        to_field_name='name',
-        help_text='Side B device'
-    )
-    side_b_type = CSVContentTypeField(
-        queryset=ContentType.objects.all(),
-        limit_choices_to=CABLE_TERMINATION_MODELS,
-        help_text='Side B type'
-    )
-    side_b_name = forms.CharField(
-        help_text='Side B component name'
-    )
-
-    # Cable attributes
-    status = CSVChoiceField(
-        choices=CableStatusChoices,
-        required=False,
-        help_text='Connection status'
-    )
-    type = CSVChoiceField(
-        choices=CableTypeChoices,
-        required=False,
-        help_text='Physical medium classification'
-    )
-    length_unit = CSVChoiceField(
-        choices=CableLengthUnitChoices,
-        required=False,
-        help_text='Length unit'
-    )
-
-    class Meta:
-        model = Cable
-        fields = [
-            'side_a_device', 'side_a_type', 'side_a_name', 'side_b_device', 'side_b_type', 'side_b_name', 'type',
-            'status', 'label', 'color', 'length', 'length_unit',
-        ]
-        help_texts = {
-            'color': mark_safe('RGB color in hexadecimal (e.g. <code>00ff00</code>)'),
-        }
-
-    def _clean_side(self, side):
-        """
-        Derive a Cable's A/B termination objects.
-
-        :param side: 'a' or 'b'
-        """
-        assert side in 'ab', f"Invalid side designation: {side}"
-
-        device = self.cleaned_data.get(f'side_{side}_device')
-        content_type = self.cleaned_data.get(f'side_{side}_type')
-        name = self.cleaned_data.get(f'side_{side}_name')
-        if not device or not content_type or not name:
-            return None
-
-        model = content_type.model_class()
-        try:
-            termination_object = model.objects.get(device=device, name=name)
-            if termination_object.cable is not None:
-                raise forms.ValidationError(f"Side {side.upper()}: {device} {termination_object} is already connected")
-        except ObjectDoesNotExist:
-            raise forms.ValidationError(f"{side.upper()} side termination not found: {device} {name}")
-
-        setattr(self.instance, f'termination_{side}', termination_object)
-        return termination_object
-
-    def clean_side_a_name(self):
-        return self._clean_side('a')
-
-    def clean_side_b_name(self):
-        return self._clean_side('b')
-
-    def clean_length_unit(self):
-        # Avoid trying to save as NULL
-        length_unit = self.cleaned_data.get('length_unit', None)
-        return length_unit if length_unit is not None else ''
-
-
-class CableBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=Cable.objects.all(),
-        widget=forms.MultipleHiddenInput
-    )
-    type = forms.ChoiceField(
-        choices=add_blank_choice(CableTypeChoices),
-        required=False,
-        initial='',
-        widget=StaticSelect()
-    )
-    status = forms.ChoiceField(
-        choices=add_blank_choice(CableStatusChoices),
-        required=False,
-        widget=StaticSelect(),
-        initial=''
-    )
-    label = forms.CharField(
-        max_length=100,
-        required=False
-    )
-    color = ColorField(
-        required=False
-    )
-    length = forms.DecimalField(
-        min_value=0,
-        required=False
-    )
-    length_unit = forms.ChoiceField(
-        choices=add_blank_choice(CableLengthUnitChoices),
-        required=False,
-        initial='',
-        widget=StaticSelect()
-    )
-
-    class Meta:
-        nullable_fields = [
-            'type', 'status', 'label', 'color', 'length',
-        ]
-
-    def clean(self):
-        super().clean()
-
-        # Validate length/unit
-        length = self.cleaned_data.get('length')
-        length_unit = self.cleaned_data.get('length_unit')
-        if length and not length_unit:
-            raise forms.ValidationError({
-                'length_unit': "Must specify a unit when setting length"
-            })
-
-
-class CableFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
-    model = Cable
-    field_groups = [
-        ['q', 'tag'],
-        ['site_id', 'rack_id', 'device_id'],
-        ['type', 'status', 'color'],
-        ['tenant_id'],
-    ]
-    q = forms.CharField(
-        required=False,
-        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
-        label=_('Search')
-    )
-    region_id = DynamicModelMultipleChoiceField(
-        queryset=Region.objects.all(),
-        required=False,
-        label=_('Region'),
-        fetch_trigger='open'
-    )
-    site_id = DynamicModelMultipleChoiceField(
-        queryset=Site.objects.all(),
-        required=False,
-        query_params={
-            'region_id': '$region_id'
-        },
-        label=_('Site'),
-        fetch_trigger='open'
-    )
-    tenant_id = DynamicModelMultipleChoiceField(
-        queryset=Tenant.objects.all(),
-        required=False,
-        label=_('Tenant'),
-        fetch_trigger='open'
-    )
-    rack_id = DynamicModelMultipleChoiceField(
-        queryset=Rack.objects.all(),
-        required=False,
-        label=_('Rack'),
-        null_option='None',
-        query_params={
-            'site_id': '$site_id'
-        },
-        fetch_trigger='open'
-    )
-    type = forms.MultipleChoiceField(
-        choices=add_blank_choice(CableTypeChoices),
-        required=False,
-        widget=StaticSelect()
-    )
-    status = forms.ChoiceField(
-        required=False,
-        choices=add_blank_choice(CableStatusChoices),
-        widget=StaticSelect()
-    )
-    color = ColorField(
-        required=False
-    )
-    device_id = DynamicModelMultipleChoiceField(
-        queryset=Device.objects.all(),
-        required=False,
-        query_params={
-            'site_id': '$site_id',
-            'tenant_id': '$tenant_id',
-            'rack_id': '$rack_id',
-        },
-        label=_('Device'),
-        fetch_trigger='open'
-    )
-    tag = TagFilterField(model)
-
-
-#
-# Connections
-#
-
-class ConsoleConnectionFilterForm(BootstrapMixin, forms.Form):
-    region_id = DynamicModelMultipleChoiceField(
-        queryset=Region.objects.all(),
-        required=False,
-        label=_('Region'),
-        fetch_trigger='open'
-    )
-    site_id = DynamicModelMultipleChoiceField(
-        queryset=Site.objects.all(),
-        required=False,
-        query_params={
-            'region_id': '$region_id'
-        },
-        label=_('Site'),
-        fetch_trigger='open'
-    )
-    device_id = DynamicModelMultipleChoiceField(
-        queryset=Device.objects.all(),
-        required=False,
-        query_params={
-            'site_id': '$site_id'
-        },
-        label=_('Device'),
-        fetch_trigger='open'
-    )
-
-
-class PowerConnectionFilterForm(BootstrapMixin, forms.Form):
-    region_id = DynamicModelMultipleChoiceField(
-        queryset=Region.objects.all(),
-        required=False,
-        label=_('Region'),
-        fetch_trigger='open'
-    )
-    site_id = DynamicModelMultipleChoiceField(
-        queryset=Site.objects.all(),
-        required=False,
-        query_params={
-            'region_id': '$region_id'
-        },
-        label=_('Site'),
-        fetch_trigger='open'
-    )
-    device_id = DynamicModelMultipleChoiceField(
-        queryset=Device.objects.all(),
-        required=False,
-        query_params={
-            'site_id': '$site_id'
-        },
-        label=_('Device'),
-        fetch_trigger='open'
-    )
-
-
-class InterfaceConnectionFilterForm(BootstrapMixin, forms.Form):
-    region_id = DynamicModelMultipleChoiceField(
-        queryset=Region.objects.all(),
-        required=False,
-        label=_('Region'),
-        fetch_trigger='open'
-    )
-    site_id = DynamicModelMultipleChoiceField(
-        queryset=Site.objects.all(),
-        required=False,
-        query_params={
-            'region_id': '$region_id'
-        },
-        label=_('Site'),
-        fetch_trigger='open'
-    )
-    device_id = DynamicModelMultipleChoiceField(
-        queryset=Device.objects.all(),
-        required=False,
-        query_params={
-            'site_id': '$site_id'
-        },
-        label=_('Device'),
-        fetch_trigger='open'
-    )
-
-
-#
-# Virtual chassis
-#
-
-class DeviceSelectionForm(forms.Form):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=Device.objects.all(),
-        widget=forms.MultipleHiddenInput()
-    )
-
-
-class VirtualChassisCreateForm(BootstrapMixin, CustomFieldModelForm):
-    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',
-        }
-    )
-    rack = DynamicModelChoiceField(
-        queryset=Rack.objects.all(),
-        required=False,
-        null_option='None',
-        query_params={
-            'site_id': '$site'
-        }
-    )
-    members = DynamicModelMultipleChoiceField(
-        queryset=Device.objects.all(),
-        required=False,
-        query_params={
-            'site_id': '$site',
-            'rack_id': '$rack',
-        }
-    )
-    initial_position = forms.IntegerField(
-        initial=1,
-        required=False,
-        help_text='Position of the first member device. Increases by one for each additional member.'
-    )
-    tags = DynamicModelMultipleChoiceField(
-        queryset=Tag.objects.all(),
-        required=False
-    )
-
-    class Meta:
-        model = VirtualChassis
-        fields = [
-            'name', 'domain', 'region', 'site_group', 'site', 'rack', 'members', 'initial_position', 'tags',
-        ]
-
-    def save(self, *args, **kwargs):
-        instance = super().save(*args, **kwargs)
-
-        # Assign VC members
-        if instance.pk:
-            initial_position = self.cleaned_data.get('initial_position') or 1
-            for i, member in enumerate(self.cleaned_data['members'], start=initial_position):
-                member.virtual_chassis = instance
-                member.vc_position = i
-                member.save()
-
-        return instance
-
-
-class VirtualChassisForm(BootstrapMixin, CustomFieldModelForm):
-    master = forms.ModelChoiceField(
-        queryset=Device.objects.all(),
-        required=False,
-    )
-    tags = DynamicModelMultipleChoiceField(
-        queryset=Tag.objects.all(),
-        required=False
-    )
-
-    class Meta:
-        model = VirtualChassis
-        fields = [
-            'name', 'domain', 'master', 'tags',
-        ]
-        widgets = {
-            'master': SelectWithPK(),
-        }
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-
-        self.fields['master'].queryset = Device.objects.filter(virtual_chassis=self.instance)
-
-
-class BaseVCMemberFormSet(forms.BaseModelFormSet):
-
-    def clean(self):
-        super().clean()
-
-        # Check for duplicate VC position values
-        vc_position_list = []
-        for form in self.forms:
-            vc_position = form.cleaned_data.get('vc_position')
-            if vc_position:
-                if vc_position in vc_position_list:
-                    error_msg = 'A virtual chassis member already exists in position {}.'.format(vc_position)
-                    form.add_error('vc_position', error_msg)
-                vc_position_list.append(vc_position)
-
-
-class DeviceVCMembershipForm(forms.ModelForm):
-
-    class Meta:
-        model = Device
-        fields = [
-            'vc_position', 'vc_priority',
-        ]
-        labels = {
-            'vc_position': 'Position',
-            'vc_priority': 'Priority',
-        }
-
-    def __init__(self, validate_vc_position=False, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-
-        # Require VC position (only required when the Device is a VirtualChassis member)
-        self.fields['vc_position'].required = True
-
-        # Add bootstrap classes to form elements.
-        self.fields['vc_position'].widget.attrs = {'class': 'form-control'}
-        self.fields['vc_priority'].widget.attrs = {'class': 'form-control'}
-
-        # Validation of vc_position is optional. This is only required when adding a new member to an existing
-        # VirtualChassis. Otherwise, vc_position validation is handled by BaseVCMemberFormSet.
-        self.validate_vc_position = validate_vc_position
-
-    def clean_vc_position(self):
-        vc_position = self.cleaned_data['vc_position']
-
-        if self.validate_vc_position:
-            conflicting_members = Device.objects.filter(
-                virtual_chassis=self.instance.virtual_chassis,
-                vc_position=vc_position
-            )
-            if conflicting_members.exists():
-                raise forms.ValidationError(
-                    'A virtual chassis member already exists in position {}.'.format(vc_position)
-                )
-
-        return vc_position
-
-
-class VCMemberSelectForm(BootstrapMixin, forms.Form):
-    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',
-        }
-    )
-    rack = DynamicModelChoiceField(
-        queryset=Rack.objects.all(),
-        required=False,
-        null_option='None',
-        query_params={
-            'site_id': '$site'
-        }
-    )
-    device = DynamicModelChoiceField(
-        queryset=Device.objects.all(),
-        query_params={
-            'site_id': '$site',
-            'rack_id': '$rack',
-            'virtual_chassis_id': 'null',
-        }
-    )
-
-    def clean_device(self):
-        device = self.cleaned_data['device']
-        if device.virtual_chassis is not None:
-            raise forms.ValidationError(
-                f"Device {device} is already assigned to a virtual chassis."
-            )
-        return device
-
-
-class VirtualChassisBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=VirtualChassis.objects.all(),
-        widget=forms.MultipleHiddenInput()
-    )
-    domain = forms.CharField(
-        max_length=30,
-        required=False
-    )
-
-    class Meta:
-        nullable_fields = ['domain']
-
-
-class VirtualChassisCSVForm(CustomFieldModelCSVForm):
-    master = CSVModelChoiceField(
-        queryset=Device.objects.all(),
-        to_field_name='name',
-        required=False,
-        help_text='Master device'
-    )
-
-    class Meta:
-        model = VirtualChassis
-        fields = ('name', 'domain', 'master')
-
-
-class VirtualChassisFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
-    model = VirtualChassis
-    field_order = ['q', 'region_id', 'site_group_id', 'site_id', 'tenant_group_id', 'tenant_id']
-    field_groups = [
-        ['q', 'tag'],
-        ['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')
-    )
-    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,
-        query_params={
-            'region_id': '$region_id',
-            'group_id': '$site_group_id',
-        },
-        label=_('Site'),
-        fetch_trigger='open'
-    )
-    tag = TagFilterField(model)
-
-
-#
-# Power panels
-#
-
-class PowerPanelForm(BootstrapMixin, CustomFieldModelForm):
-    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(),
-        query_params={
-            'region_id': '$region',
-            'group_id': '$site_group',
-        }
-    )
-    location = DynamicModelChoiceField(
-        queryset=Location.objects.all(),
-        required=False,
-        query_params={
-            'site_id': '$site'
-        }
-    )
-    tags = DynamicModelMultipleChoiceField(
-        queryset=Tag.objects.all(),
-        required=False
-    )
-
-    class Meta:
-        model = PowerPanel
-        fields = [
-            'region', 'site_group', 'site', 'location', 'name', 'tags',
-        ]
-        fieldsets = (
-            ('Power Panel', ('region', 'site_group', 'site', 'location', 'name', 'tags')),
-        )
-
-
-class PowerPanelCSVForm(CustomFieldModelCSVForm):
-    site = CSVModelChoiceField(
-        queryset=Site.objects.all(),
-        to_field_name='name',
-        help_text='Name of parent site'
-    )
-    location = CSVModelChoiceField(
-        queryset=Location.objects.all(),
-        required=False,
-        to_field_name='name'
-    )
-
-    class Meta:
-        model = PowerPanel
-        fields = ('site', 'location', 'name')
-
-    def __init__(self, data=None, *args, **kwargs):
-        super().__init__(data, *args, **kwargs)
-
-        if data:
-
-            # Limit group queryset by assigned site
-            params = {f"site__{self.fields['site'].to_field_name}": data.get('site')}
-            self.fields['location'].queryset = self.fields['location'].queryset.filter(**params)
-
-
-class PowerPanelBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=PowerPanel.objects.all(),
-        widget=forms.MultipleHiddenInput
-    )
-    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',
-        }
-    )
-    location = DynamicModelChoiceField(
-        queryset=Location.objects.all(),
-        required=False,
-        query_params={
-            'site_id': '$site'
-        }
-    )
-
-    class Meta:
-        nullable_fields = ['location']
-
-
-class PowerPanelFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
-    model = PowerPanel
-    field_groups = (
-        ('q', 'tag'),
-        ('region_id', 'site_group_id', 'site_id', 'location_id')
-    )
-    q = forms.CharField(
-        required=False,
-        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
-        label=_('Search')
-    )
-    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,
-        query_params={
-            'region_id': '$region_id',
-            'group_id': '$site_group_id',
-        },
-        label=_('Site'),
-        fetch_trigger='open'
-    )
-    location_id = DynamicModelMultipleChoiceField(
-        queryset=Location.objects.all(),
-        required=False,
-        null_option='None',
-        query_params={
-            'site_id': '$site_id'
-        },
-        label=_('Location'),
-        fetch_trigger='open'
-    )
-    tag = TagFilterField(model)
-
-
-#
-# Power feeds
-#
-
-class PowerFeedForm(BootstrapMixin, CustomFieldModelForm):
-    region = DynamicModelChoiceField(
-        queryset=Region.objects.all(),
-        required=False,
-        initial_params={
-            'sites__powerpanel': '$power_panel'
-        }
-    )
-    site_group = DynamicModelChoiceField(
-        queryset=SiteGroup.objects.all(),
-        required=False,
-        initial_params={
-            'sites': '$site'
-        }
-    )
-    site = DynamicModelChoiceField(
-        queryset=Site.objects.all(),
-        required=False,
-        initial_params={
-            'powerpanel': '$power_panel'
-        },
-        query_params={
-            'region_id': '$region',
-            'group_id': '$site_group',
-        }
-    )
-    power_panel = DynamicModelChoiceField(
-        queryset=PowerPanel.objects.all(),
-        query_params={
-            'site_id': '$site'
-        }
-    )
-    rack = DynamicModelChoiceField(
-        queryset=Rack.objects.all(),
-        required=False,
-        query_params={
-            'site_id': '$site'
-        }
-    )
-    comments = CommentField()
-    tags = DynamicModelMultipleChoiceField(
-        queryset=Tag.objects.all(),
-        required=False
-    )
-
-    class Meta:
-        model = PowerFeed
-        fields = [
-            'region', 'site_group', 'site', 'power_panel', 'rack', 'name', 'status', 'type', 'mark_connected', 'supply',
-            'phase', 'voltage', 'amperage', 'max_utilization', 'comments', 'tags',
-        ]
-        fieldsets = (
-            ('Power Panel', ('region', 'site', 'power_panel')),
-            ('Power Feed', ('rack', 'name', 'status', 'type', 'mark_connected', 'tags')),
-            ('Characteristics', ('supply', 'voltage', 'amperage', 'phase', 'max_utilization')),
-        )
-        widgets = {
-            'status': StaticSelect(),
-            'type': StaticSelect(),
-            'supply': StaticSelect(),
-            'phase': StaticSelect(),
-        }
-
-
-class PowerFeedCSVForm(CustomFieldModelCSVForm):
-    site = CSVModelChoiceField(
-        queryset=Site.objects.all(),
-        to_field_name='name',
-        help_text='Assigned site'
-    )
-    power_panel = CSVModelChoiceField(
-        queryset=PowerPanel.objects.all(),
-        to_field_name='name',
-        help_text='Upstream power panel'
-    )
-    location = CSVModelChoiceField(
-        queryset=Location.objects.all(),
-        to_field_name='name',
-        required=False,
-        help_text="Rack's location (if any)"
-    )
-    rack = CSVModelChoiceField(
-        queryset=Rack.objects.all(),
-        to_field_name='name',
-        required=False,
-        help_text='Rack'
-    )
-    status = CSVChoiceField(
-        choices=PowerFeedStatusChoices,
-        required=False,
-        help_text='Operational status'
-    )
-    type = CSVChoiceField(
-        choices=PowerFeedTypeChoices,
-        required=False,
-        help_text='Primary or redundant'
-    )
-    supply = CSVChoiceField(
-        choices=PowerFeedSupplyChoices,
-        required=False,
-        help_text='Supply type (AC/DC)'
-    )
-    phase = CSVChoiceField(
-        choices=PowerFeedPhaseChoices,
-        required=False,
-        help_text='Single or three-phase'
-    )
-
-    class Meta:
-        model = PowerFeed
-        fields = (
-            'site', 'power_panel', 'location', 'rack', 'name', 'status', 'type', 'mark_connected', 'supply', 'phase',
-            'voltage', 'amperage', 'max_utilization', 'comments',
-        )
-
-    def __init__(self, data=None, *args, **kwargs):
-        super().__init__(data, *args, **kwargs)
-
-        if data:
-
-            # Limit power_panel queryset by site
-            params = {f"site__{self.fields['site'].to_field_name}": data.get('site')}
-            self.fields['power_panel'].queryset = self.fields['power_panel'].queryset.filter(**params)
-
-            # Limit location queryset by site
-            params = {f"site__{self.fields['site'].to_field_name}": data.get('site')}
-            self.fields['location'].queryset = self.fields['location'].queryset.filter(**params)
-
-            # Limit rack queryset by site and group
-            params = {
-                f"site__{self.fields['site'].to_field_name}": data.get('site'),
-                f"location__{self.fields['location'].to_field_name}": data.get('location'),
-            }
-            self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params)
-
-
-class PowerFeedBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=PowerFeed.objects.all(),
-        widget=forms.MultipleHiddenInput
-    )
-    power_panel = DynamicModelChoiceField(
-        queryset=PowerPanel.objects.all(),
-        required=False
-    )
-    rack = DynamicModelChoiceField(
-        queryset=Rack.objects.all(),
-        required=False,
-    )
-    status = forms.ChoiceField(
-        choices=add_blank_choice(PowerFeedStatusChoices),
-        required=False,
-        initial='',
-        widget=StaticSelect()
-    )
-    type = forms.ChoiceField(
-        choices=add_blank_choice(PowerFeedTypeChoices),
-        required=False,
-        initial='',
-        widget=StaticSelect()
-    )
-    supply = forms.ChoiceField(
-        choices=add_blank_choice(PowerFeedSupplyChoices),
-        required=False,
-        initial='',
-        widget=StaticSelect()
-    )
-    phase = forms.ChoiceField(
-        choices=add_blank_choice(PowerFeedPhaseChoices),
-        required=False,
-        initial='',
-        widget=StaticSelect()
-    )
-    voltage = forms.IntegerField(
-        required=False
-    )
-    amperage = forms.IntegerField(
-        required=False
-    )
-    max_utilization = forms.IntegerField(
-        required=False
-    )
-    mark_connected = forms.NullBooleanField(
-        required=False,
-        widget=BulkEditNullBooleanSelect
-    )
-    comments = CommentField(
-        widget=SmallTextarea,
-        label='Comments'
-    )
-
-    class Meta:
-        nullable_fields = [
-            'location', 'comments',
-        ]
-
-
-class PowerFeedFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
-    model = PowerFeed
-    field_groups = [
-        ['q', 'tag'],
-        ['region_id', 'site_group_id', 'site_id'],
-        ['power_panel_id', 'rack_id'],
-        ['status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization'],
-    ]
-    q = forms.CharField(
-        required=False,
-        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
-        label=_('Search')
-    )
-    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,
-        query_params={
-            'region_id': '$region_id'
-        },
-        label=_('Site'),
-        fetch_trigger='open'
-    )
-    power_panel_id = DynamicModelMultipleChoiceField(
-        queryset=PowerPanel.objects.all(),
-        required=False,
-        null_option='None',
-        query_params={
-            'site_id': '$site_id'
-        },
-        label=_('Power panel'),
-        fetch_trigger='open'
-    )
-    rack_id = DynamicModelMultipleChoiceField(
-        queryset=Rack.objects.all(),
-        required=False,
-        null_option='None',
-        query_params={
-            'site_id': '$site_id'
-        },
-        label=_('Rack'),
-        fetch_trigger='open'
-    )
-    status = forms.MultipleChoiceField(
-        choices=PowerFeedStatusChoices,
-        required=False,
-        widget=StaticSelectMultiple()
-    )
-    type = forms.ChoiceField(
-        choices=add_blank_choice(PowerFeedTypeChoices),
-        required=False,
-        widget=StaticSelect()
-    )
-    supply = forms.ChoiceField(
-        choices=add_blank_choice(PowerFeedSupplyChoices),
-        required=False,
-        widget=StaticSelect()
-    )
-    phase = forms.ChoiceField(
-        choices=add_blank_choice(PowerFeedPhaseChoices),
-        required=False,
-        widget=StaticSelect()
-    )
-    voltage = forms.IntegerField(
-        required=False
-    )
-    amperage = forms.IntegerField(
-        required=False
-    )
-    max_utilization = forms.IntegerField(
-        required=False
-    )
-    tag = TagFilterField(model)

+ 10 - 0
netbox/dcim/forms/__init__.py

@@ -0,0 +1,10 @@
+from .fields import *
+from .models import *
+from .filtersets import *
+from .object_create import *
+from .object_import import *
+from .bulk_create import *
+from .bulk_edit import *
+from .bulk_import import *
+from .connections import *
+from .formsets import *

+ 111 - 0
netbox/dcim/forms/bulk_create.py

@@ -0,0 +1,111 @@
+from django import forms
+
+from dcim.models import *
+from extras.forms import CustomFieldsMixin
+from extras.models import Tag
+from utilities.forms import BootstrapMixin, DynamicModelMultipleChoiceField, form_from_model
+from .object_create import ComponentForm
+
+__all__ = (
+    'ConsolePortBulkCreateForm',
+    'ConsoleServerPortBulkCreateForm',
+    'DeviceBayBulkCreateForm',
+    # 'FrontPortBulkCreateForm',
+    'InterfaceBulkCreateForm',
+    'InventoryItemBulkCreateForm',
+    'PowerOutletBulkCreateForm',
+    'PowerPortBulkCreateForm',
+    'RearPortBulkCreateForm',
+)
+
+
+#
+# Device components
+#
+
+class DeviceBulkAddComponentForm(BootstrapMixin, CustomFieldsMixin, ComponentForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=Device.objects.all(),
+        widget=forms.MultipleHiddenInput()
+    )
+    description = forms.CharField(
+        max_length=100,
+        required=False
+    )
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
+        required=False
+    )
+
+
+class ConsolePortBulkCreateForm(
+    form_from_model(ConsolePort, ['type', 'speed', 'mark_connected']),
+    DeviceBulkAddComponentForm
+):
+    model = ConsolePort
+    field_order = ('name_pattern', 'label_pattern', 'type', 'mark_connected', 'description', 'tags')
+
+
+class ConsoleServerPortBulkCreateForm(
+    form_from_model(ConsoleServerPort, ['type', 'speed', 'mark_connected']),
+    DeviceBulkAddComponentForm
+):
+    model = ConsoleServerPort
+    field_order = ('name_pattern', 'label_pattern', 'type', 'speed', 'description', 'tags')
+
+
+class PowerPortBulkCreateForm(
+    form_from_model(PowerPort, ['type', 'maximum_draw', 'allocated_draw', 'mark_connected']),
+    DeviceBulkAddComponentForm
+):
+    model = PowerPort
+    field_order = ('name_pattern', 'label_pattern', 'type', 'maximum_draw', 'allocated_draw', 'description', 'tags')
+
+
+class PowerOutletBulkCreateForm(
+    form_from_model(PowerOutlet, ['type', 'feed_leg', 'mark_connected']),
+    DeviceBulkAddComponentForm
+):
+    model = PowerOutlet
+    field_order = ('name_pattern', 'label_pattern', 'type', 'feed_leg', 'description', 'tags')
+
+
+class InterfaceBulkCreateForm(
+    form_from_model(Interface, ['type', 'enabled', 'mtu', 'mgmt_only', 'mark_connected']),
+    DeviceBulkAddComponentForm
+):
+    model = Interface
+    field_order = (
+        'name_pattern', 'label_pattern', 'type', 'enabled', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'tags',
+    )
+
+
+# class FrontPortBulkCreateForm(
+#     form_from_model(FrontPort, ['label', 'type', 'description', 'tags']),
+#     DeviceBulkAddComponentForm
+# ):
+#     pass
+
+
+class RearPortBulkCreateForm(
+    form_from_model(RearPort, ['type', 'color', 'positions', 'mark_connected']),
+    DeviceBulkAddComponentForm
+):
+    model = RearPort
+    field_order = ('name_pattern', 'label_pattern', 'type', 'positions', 'mark_connected', 'description', 'tags')
+
+
+class DeviceBayBulkCreateForm(DeviceBulkAddComponentForm):
+    model = DeviceBay
+    field_order = ('name_pattern', 'label_pattern', 'description', 'tags')
+
+
+class InventoryItemBulkCreateForm(
+    form_from_model(InventoryItem, ['manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered']),
+    DeviceBulkAddComponentForm
+):
+    model = InventoryItem
+    field_order = (
+        'name_pattern', 'label_pattern', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', 'description',
+        'tags',
+    )

+ 1090 - 0
netbox/dcim/forms/bulk_edit.py

@@ -0,0 +1,1090 @@
+from django import forms
+from django.contrib.auth.models import User
+from timezone_field import TimeZoneFormField
+
+from dcim.choices import *
+from dcim.constants import *
+from dcim.models import *
+from extras.forms import AddRemoveTagsForm, CustomFieldModelBulkEditForm
+from ipam.constants import BGP_ASN_MAX, BGP_ASN_MIN
+from ipam.models import VLAN
+from tenancy.models import Tenant
+from utilities.forms import (
+    add_blank_choice, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorField, CommentField,
+    DynamicModelChoiceField, DynamicModelMultipleChoiceField, form_from_model, SmallTextarea, StaticSelect,
+)
+
+__all__ = (
+    'CableBulkEditForm',
+    'ConsolePortBulkEditForm',
+    'ConsolePortTemplateBulkEditForm',
+    'ConsoleServerPortBulkEditForm',
+    'ConsoleServerPortTemplateBulkEditForm',
+    'DeviceBayBulkEditForm',
+    'DeviceBayTemplateBulkEditForm',
+    'DeviceBulkEditForm',
+    'DeviceRoleBulkEditForm',
+    'DeviceTypeBulkEditForm',
+    'FrontPortBulkEditForm',
+    'FrontPortTemplateBulkEditForm',
+    'InterfaceBulkEditForm',
+    'InterfaceTemplateBulkEditForm',
+    'InventoryItemBulkEditForm',
+    'LocationBulkEditForm',
+    'ManufacturerBulkEditForm',
+    'PlatformBulkEditForm',
+    'PowerFeedBulkEditForm',
+    'PowerOutletBulkEditForm',
+    'PowerOutletTemplateBulkEditForm',
+    'PowerPanelBulkEditForm',
+    'PowerPortBulkEditForm',
+    'PowerPortTemplateBulkEditForm',
+    'RackBulkEditForm',
+    'RackReservationBulkEditForm',
+    'RackRoleBulkEditForm',
+    'RearPortBulkEditForm',
+    'RearPortTemplateBulkEditForm',
+    'RegionBulkEditForm',
+    'SiteBulkEditForm',
+    'SiteGroupBulkEditForm',
+    'VirtualChassisBulkEditForm',
+)
+
+
+class RegionBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=Region.objects.all(),
+        widget=forms.MultipleHiddenInput
+    )
+    parent = DynamicModelChoiceField(
+        queryset=Region.objects.all(),
+        required=False
+    )
+    description = forms.CharField(
+        max_length=200,
+        required=False
+    )
+
+    class Meta:
+        nullable_fields = ['parent', 'description']
+
+
+class SiteGroupBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=SiteGroup.objects.all(),
+        widget=forms.MultipleHiddenInput
+    )
+    parent = DynamicModelChoiceField(
+        queryset=SiteGroup.objects.all(),
+        required=False
+    )
+    description = forms.CharField(
+        max_length=200,
+        required=False
+    )
+
+    class Meta:
+        nullable_fields = ['parent', 'description']
+
+
+class SiteBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=Site.objects.all(),
+        widget=forms.MultipleHiddenInput
+    )
+    status = forms.ChoiceField(
+        choices=add_blank_choice(SiteStatusChoices),
+        required=False,
+        initial='',
+        widget=StaticSelect()
+    )
+    region = DynamicModelChoiceField(
+        queryset=Region.objects.all(),
+        required=False
+    )
+    group = DynamicModelChoiceField(
+        queryset=SiteGroup.objects.all(),
+        required=False
+    )
+    tenant = DynamicModelChoiceField(
+        queryset=Tenant.objects.all(),
+        required=False
+    )
+    asn = forms.IntegerField(
+        min_value=BGP_ASN_MIN,
+        max_value=BGP_ASN_MAX,
+        required=False,
+        label='ASN'
+    )
+    description = forms.CharField(
+        max_length=100,
+        required=False
+    )
+    time_zone = TimeZoneFormField(
+        choices=add_blank_choice(TimeZoneFormField().choices),
+        required=False,
+        widget=StaticSelect()
+    )
+
+    class Meta:
+        nullable_fields = [
+            'region', 'group', 'tenant', 'asn', 'description', 'time_zone',
+        ]
+
+
+class LocationBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=Location.objects.all(),
+        widget=forms.MultipleHiddenInput
+    )
+    site = DynamicModelChoiceField(
+        queryset=Site.objects.all(),
+        required=False
+    )
+    parent = DynamicModelChoiceField(
+        queryset=Location.objects.all(),
+        required=False,
+        query_params={
+            'site_id': '$site'
+        }
+    )
+    description = forms.CharField(
+        max_length=200,
+        required=False
+    )
+
+    class Meta:
+        nullable_fields = ['parent', 'description']
+
+
+class RackRoleBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=RackRole.objects.all(),
+        widget=forms.MultipleHiddenInput
+    )
+    color = ColorField(
+        required=False
+    )
+    description = forms.CharField(
+        max_length=200,
+        required=False
+    )
+
+    class Meta:
+        nullable_fields = ['color', 'description']
+
+
+class RackBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=Rack.objects.all(),
+        widget=forms.MultipleHiddenInput
+    )
+    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',
+        }
+    )
+    location = DynamicModelChoiceField(
+        queryset=Location.objects.all(),
+        required=False,
+        query_params={
+            'site_id': '$site'
+        }
+    )
+    tenant = DynamicModelChoiceField(
+        queryset=Tenant.objects.all(),
+        required=False
+    )
+    status = forms.ChoiceField(
+        choices=add_blank_choice(RackStatusChoices),
+        required=False,
+        initial='',
+        widget=StaticSelect()
+    )
+    role = DynamicModelChoiceField(
+        queryset=RackRole.objects.all(),
+        required=False
+    )
+    serial = forms.CharField(
+        max_length=50,
+        required=False,
+        label='Serial Number'
+    )
+    asset_tag = forms.CharField(
+        max_length=50,
+        required=False
+    )
+    type = forms.ChoiceField(
+        choices=add_blank_choice(RackTypeChoices),
+        required=False,
+        widget=StaticSelect()
+    )
+    width = forms.ChoiceField(
+        choices=add_blank_choice(RackWidthChoices),
+        required=False,
+        widget=StaticSelect()
+    )
+    u_height = forms.IntegerField(
+        required=False,
+        label='Height (U)'
+    )
+    desc_units = forms.NullBooleanField(
+        required=False,
+        widget=BulkEditNullBooleanSelect,
+        label='Descending units'
+    )
+    outer_width = forms.IntegerField(
+        required=False,
+        min_value=1
+    )
+    outer_depth = forms.IntegerField(
+        required=False,
+        min_value=1
+    )
+    outer_unit = forms.ChoiceField(
+        choices=add_blank_choice(RackDimensionUnitChoices),
+        required=False,
+        widget=StaticSelect()
+    )
+    comments = CommentField(
+        widget=SmallTextarea,
+        label='Comments'
+    )
+
+    class Meta:
+        nullable_fields = [
+            'location', 'tenant', 'role', 'serial', 'asset_tag', 'outer_width', 'outer_depth', 'outer_unit', 'comments',
+        ]
+
+
+class RackReservationBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=RackReservation.objects.all(),
+        widget=forms.MultipleHiddenInput()
+    )
+    user = forms.ModelChoiceField(
+        queryset=User.objects.order_by(
+            'username'
+        ),
+        required=False,
+        widget=StaticSelect()
+    )
+    tenant = DynamicModelChoiceField(
+        queryset=Tenant.objects.all(),
+        required=False
+    )
+    description = forms.CharField(
+        max_length=100,
+        required=False
+    )
+
+    class Meta:
+        nullable_fields = []
+
+
+class ManufacturerBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=Manufacturer.objects.all(),
+        widget=forms.MultipleHiddenInput
+    )
+    description = forms.CharField(
+        max_length=200,
+        required=False
+    )
+
+    class Meta:
+        nullable_fields = ['description']
+
+
+class DeviceTypeBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=DeviceType.objects.all(),
+        widget=forms.MultipleHiddenInput()
+    )
+    manufacturer = DynamicModelChoiceField(
+        queryset=Manufacturer.objects.all(),
+        required=False
+    )
+    u_height = forms.IntegerField(
+        min_value=1,
+        required=False
+    )
+    is_full_depth = forms.NullBooleanField(
+        required=False,
+        widget=BulkEditNullBooleanSelect(),
+        label='Is full depth'
+    )
+
+    class Meta:
+        nullable_fields = []
+
+
+class DeviceRoleBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=DeviceRole.objects.all(),
+        widget=forms.MultipleHiddenInput
+    )
+    color = ColorField(
+        required=False
+    )
+    vm_role = forms.NullBooleanField(
+        required=False,
+        widget=BulkEditNullBooleanSelect,
+        label='VM role'
+    )
+    description = forms.CharField(
+        max_length=200,
+        required=False
+    )
+
+    class Meta:
+        nullable_fields = ['color', 'description']
+
+
+class PlatformBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=Platform.objects.all(),
+        widget=forms.MultipleHiddenInput
+    )
+    manufacturer = DynamicModelChoiceField(
+        queryset=Manufacturer.objects.all(),
+        required=False
+    )
+    napalm_driver = forms.CharField(
+        max_length=50,
+        required=False
+    )
+    # TODO: Bulk edit support for napalm_args
+    description = forms.CharField(
+        max_length=200,
+        required=False
+    )
+
+    class Meta:
+        nullable_fields = ['manufacturer', 'napalm_driver', 'description']
+
+
+class DeviceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=Device.objects.all(),
+        widget=forms.MultipleHiddenInput()
+    )
+    manufacturer = DynamicModelChoiceField(
+        queryset=Manufacturer.objects.all(),
+        required=False
+    )
+    device_type = DynamicModelChoiceField(
+        queryset=DeviceType.objects.all(),
+        required=False,
+        query_params={
+            'manufacturer_id': '$manufacturer'
+        }
+    )
+    device_role = DynamicModelChoiceField(
+        queryset=DeviceRole.objects.all(),
+        required=False
+    )
+    site = DynamicModelChoiceField(
+        queryset=Site.objects.all(),
+        required=False
+    )
+    location = DynamicModelChoiceField(
+        queryset=Location.objects.all(),
+        required=False,
+        query_params={
+            'site_id': '$site'
+        }
+    )
+    tenant = DynamicModelChoiceField(
+        queryset=Tenant.objects.all(),
+        required=False
+    )
+    platform = DynamicModelChoiceField(
+        queryset=Platform.objects.all(),
+        required=False
+    )
+    status = forms.ChoiceField(
+        choices=add_blank_choice(DeviceStatusChoices),
+        required=False,
+        widget=StaticSelect()
+    )
+    serial = forms.CharField(
+        max_length=50,
+        required=False,
+        label='Serial Number'
+    )
+
+    class Meta:
+        nullable_fields = [
+            'tenant', 'platform', 'serial',
+        ]
+
+
+class CableBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=Cable.objects.all(),
+        widget=forms.MultipleHiddenInput
+    )
+    type = forms.ChoiceField(
+        choices=add_blank_choice(CableTypeChoices),
+        required=False,
+        initial='',
+        widget=StaticSelect()
+    )
+    status = forms.ChoiceField(
+        choices=add_blank_choice(CableStatusChoices),
+        required=False,
+        widget=StaticSelect(),
+        initial=''
+    )
+    label = forms.CharField(
+        max_length=100,
+        required=False
+    )
+    color = ColorField(
+        required=False
+    )
+    length = forms.DecimalField(
+        min_value=0,
+        required=False
+    )
+    length_unit = forms.ChoiceField(
+        choices=add_blank_choice(CableLengthUnitChoices),
+        required=False,
+        initial='',
+        widget=StaticSelect()
+    )
+
+    class Meta:
+        nullable_fields = [
+            'type', 'status', 'label', 'color', 'length',
+        ]
+
+    def clean(self):
+        super().clean()
+
+        # Validate length/unit
+        length = self.cleaned_data.get('length')
+        length_unit = self.cleaned_data.get('length_unit')
+        if length and not length_unit:
+            raise forms.ValidationError({
+                'length_unit': "Must specify a unit when setting length"
+            })
+
+
+class VirtualChassisBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=VirtualChassis.objects.all(),
+        widget=forms.MultipleHiddenInput()
+    )
+    domain = forms.CharField(
+        max_length=30,
+        required=False
+    )
+
+    class Meta:
+        nullable_fields = ['domain']
+
+
+class PowerPanelBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=PowerPanel.objects.all(),
+        widget=forms.MultipleHiddenInput
+    )
+    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',
+        }
+    )
+    location = DynamicModelChoiceField(
+        queryset=Location.objects.all(),
+        required=False,
+        query_params={
+            'site_id': '$site'
+        }
+    )
+
+    class Meta:
+        nullable_fields = ['location']
+
+
+class PowerFeedBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=PowerFeed.objects.all(),
+        widget=forms.MultipleHiddenInput
+    )
+    power_panel = DynamicModelChoiceField(
+        queryset=PowerPanel.objects.all(),
+        required=False
+    )
+    rack = DynamicModelChoiceField(
+        queryset=Rack.objects.all(),
+        required=False,
+    )
+    status = forms.ChoiceField(
+        choices=add_blank_choice(PowerFeedStatusChoices),
+        required=False,
+        initial='',
+        widget=StaticSelect()
+    )
+    type = forms.ChoiceField(
+        choices=add_blank_choice(PowerFeedTypeChoices),
+        required=False,
+        initial='',
+        widget=StaticSelect()
+    )
+    supply = forms.ChoiceField(
+        choices=add_blank_choice(PowerFeedSupplyChoices),
+        required=False,
+        initial='',
+        widget=StaticSelect()
+    )
+    phase = forms.ChoiceField(
+        choices=add_blank_choice(PowerFeedPhaseChoices),
+        required=False,
+        initial='',
+        widget=StaticSelect()
+    )
+    voltage = forms.IntegerField(
+        required=False
+    )
+    amperage = forms.IntegerField(
+        required=False
+    )
+    max_utilization = forms.IntegerField(
+        required=False
+    )
+    mark_connected = forms.NullBooleanField(
+        required=False,
+        widget=BulkEditNullBooleanSelect
+    )
+    comments = CommentField(
+        widget=SmallTextarea,
+        label='Comments'
+    )
+
+    class Meta:
+        nullable_fields = [
+            'location', 'comments',
+        ]
+
+
+#
+# Device component templates
+#
+
+class ConsolePortTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=ConsolePortTemplate.objects.all(),
+        widget=forms.MultipleHiddenInput()
+    )
+    label = forms.CharField(
+        max_length=64,
+        required=False
+    )
+    type = forms.ChoiceField(
+        choices=add_blank_choice(ConsolePortTypeChoices),
+        required=False,
+        widget=StaticSelect()
+    )
+
+    class Meta:
+        nullable_fields = ('label', 'type', 'description')
+
+
+class ConsoleServerPortTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=ConsoleServerPortTemplate.objects.all(),
+        widget=forms.MultipleHiddenInput()
+    )
+    label = forms.CharField(
+        max_length=64,
+        required=False
+    )
+    type = forms.ChoiceField(
+        choices=add_blank_choice(ConsolePortTypeChoices),
+        required=False,
+        widget=StaticSelect()
+    )
+    description = forms.CharField(
+        required=False
+    )
+
+    class Meta:
+        nullable_fields = ('label', 'type', 'description')
+
+
+class PowerPortTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=PowerPortTemplate.objects.all(),
+        widget=forms.MultipleHiddenInput()
+    )
+    label = forms.CharField(
+        max_length=64,
+        required=False
+    )
+    type = forms.ChoiceField(
+        choices=add_blank_choice(PowerPortTypeChoices),
+        required=False,
+        widget=StaticSelect()
+    )
+    maximum_draw = forms.IntegerField(
+        min_value=1,
+        required=False,
+        help_text="Maximum power draw (watts)"
+    )
+    allocated_draw = forms.IntegerField(
+        min_value=1,
+        required=False,
+        help_text="Allocated power draw (watts)"
+    )
+    description = forms.CharField(
+        required=False
+    )
+
+    class Meta:
+        nullable_fields = ('label', 'type', 'maximum_draw', 'allocated_draw', 'description')
+
+
+class PowerOutletTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=PowerOutletTemplate.objects.all(),
+        widget=forms.MultipleHiddenInput()
+    )
+    device_type = forms.ModelChoiceField(
+        queryset=DeviceType.objects.all(),
+        required=False,
+        disabled=True,
+        widget=forms.HiddenInput()
+    )
+    label = forms.CharField(
+        max_length=64,
+        required=False
+    )
+    type = forms.ChoiceField(
+        choices=add_blank_choice(PowerOutletTypeChoices),
+        required=False,
+        widget=StaticSelect()
+    )
+    power_port = forms.ModelChoiceField(
+        queryset=PowerPortTemplate.objects.all(),
+        required=False
+    )
+    feed_leg = forms.ChoiceField(
+        choices=add_blank_choice(PowerOutletFeedLegChoices),
+        required=False,
+        widget=StaticSelect()
+    )
+    description = forms.CharField(
+        required=False
+    )
+
+    class Meta:
+        nullable_fields = ('label', 'type', 'power_port', 'feed_leg', 'description')
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        # Limit power_port queryset to PowerPortTemplates which belong to the parent DeviceType
+        if 'device_type' in self.initial:
+            device_type = DeviceType.objects.filter(pk=self.initial['device_type']).first()
+            self.fields['power_port'].queryset = PowerPortTemplate.objects.filter(device_type=device_type)
+        else:
+            self.fields['power_port'].choices = ()
+            self.fields['power_port'].widget.attrs['disabled'] = True
+
+
+class InterfaceTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=InterfaceTemplate.objects.all(),
+        widget=forms.MultipleHiddenInput()
+    )
+    label = forms.CharField(
+        max_length=64,
+        required=False
+    )
+    type = forms.ChoiceField(
+        choices=add_blank_choice(InterfaceTypeChoices),
+        required=False,
+        widget=StaticSelect()
+    )
+    mgmt_only = forms.NullBooleanField(
+        required=False,
+        widget=BulkEditNullBooleanSelect,
+        label='Management only'
+    )
+    description = forms.CharField(
+        required=False
+    )
+
+    class Meta:
+        nullable_fields = ('label', 'description')
+
+
+class FrontPortTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=FrontPortTemplate.objects.all(),
+        widget=forms.MultipleHiddenInput()
+    )
+    label = forms.CharField(
+        max_length=64,
+        required=False
+    )
+    type = forms.ChoiceField(
+        choices=add_blank_choice(PortTypeChoices),
+        required=False,
+        widget=StaticSelect()
+    )
+    color = ColorField(
+        required=False
+    )
+    description = forms.CharField(
+        required=False
+    )
+
+    class Meta:
+        nullable_fields = ('description',)
+
+
+class RearPortTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=RearPortTemplate.objects.all(),
+        widget=forms.MultipleHiddenInput()
+    )
+    label = forms.CharField(
+        max_length=64,
+        required=False
+    )
+    type = forms.ChoiceField(
+        choices=add_blank_choice(PortTypeChoices),
+        required=False,
+        widget=StaticSelect()
+    )
+    color = ColorField(
+        required=False
+    )
+    description = forms.CharField(
+        required=False
+    )
+
+    class Meta:
+        nullable_fields = ('description',)
+
+
+class DeviceBayTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=DeviceBayTemplate.objects.all(),
+        widget=forms.MultipleHiddenInput()
+    )
+    label = forms.CharField(
+        max_length=64,
+        required=False
+    )
+    description = forms.CharField(
+        required=False
+    )
+
+    class Meta:
+        nullable_fields = ('label', 'description')
+
+
+#
+# Device components
+#
+
+class ConsolePortBulkEditForm(
+    form_from_model(ConsolePort, ['label', 'type', 'speed', 'mark_connected', 'description']),
+    BootstrapMixin,
+    AddRemoveTagsForm,
+    CustomFieldModelBulkEditForm
+):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=ConsolePort.objects.all(),
+        widget=forms.MultipleHiddenInput()
+    )
+    mark_connected = forms.NullBooleanField(
+        required=False,
+        widget=BulkEditNullBooleanSelect
+    )
+
+    class Meta:
+        nullable_fields = ['label', 'description']
+
+
+class ConsoleServerPortBulkEditForm(
+    form_from_model(ConsoleServerPort, ['label', 'type', 'speed', 'mark_connected', 'description']),
+    BootstrapMixin,
+    AddRemoveTagsForm,
+    CustomFieldModelBulkEditForm
+):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=ConsoleServerPort.objects.all(),
+        widget=forms.MultipleHiddenInput()
+    )
+    mark_connected = forms.NullBooleanField(
+        required=False,
+        widget=BulkEditNullBooleanSelect
+    )
+
+    class Meta:
+        nullable_fields = ['label', 'description']
+
+
+class PowerPortBulkEditForm(
+    form_from_model(PowerPort, ['label', 'type', 'maximum_draw', 'allocated_draw', 'mark_connected', 'description']),
+    BootstrapMixin,
+    AddRemoveTagsForm,
+    CustomFieldModelBulkEditForm
+):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=PowerPort.objects.all(),
+        widget=forms.MultipleHiddenInput()
+    )
+    mark_connected = forms.NullBooleanField(
+        required=False,
+        widget=BulkEditNullBooleanSelect
+    )
+
+    class Meta:
+        nullable_fields = ['label', 'description']
+
+
+class PowerOutletBulkEditForm(
+    form_from_model(PowerOutlet, ['label', 'type', 'feed_leg', 'power_port', 'mark_connected', 'description']),
+    BootstrapMixin,
+    AddRemoveTagsForm,
+    CustomFieldModelBulkEditForm
+):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=PowerOutlet.objects.all(),
+        widget=forms.MultipleHiddenInput()
+    )
+    device = forms.ModelChoiceField(
+        queryset=Device.objects.all(),
+        required=False,
+        disabled=True,
+        widget=forms.HiddenInput()
+    )
+    mark_connected = forms.NullBooleanField(
+        required=False,
+        widget=BulkEditNullBooleanSelect
+    )
+
+    class Meta:
+        nullable_fields = ['label', 'type', 'feed_leg', 'power_port', 'description']
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        # Limit power_port queryset to PowerPorts which belong to the parent Device
+        if 'device' in self.initial:
+            device = Device.objects.filter(pk=self.initial['device']).first()
+            self.fields['power_port'].queryset = PowerPort.objects.filter(device=device)
+        else:
+            self.fields['power_port'].choices = ()
+            self.fields['power_port'].widget.attrs['disabled'] = True
+
+
+class InterfaceBulkEditForm(
+    form_from_model(Interface, [
+        'label', 'type', 'parent', 'lag', 'mac_address', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'mode',
+    ]),
+    BootstrapMixin,
+    AddRemoveTagsForm,
+    CustomFieldModelBulkEditForm
+):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=Interface.objects.all(),
+        widget=forms.MultipleHiddenInput()
+    )
+    device = forms.ModelChoiceField(
+        queryset=Device.objects.all(),
+        required=False,
+        disabled=True,
+        widget=forms.HiddenInput()
+    )
+    enabled = forms.NullBooleanField(
+        required=False,
+        widget=BulkEditNullBooleanSelect
+    )
+    parent = DynamicModelChoiceField(
+        queryset=Interface.objects.all(),
+        required=False
+    )
+    lag = DynamicModelChoiceField(
+        queryset=Interface.objects.all(),
+        required=False,
+        query_params={
+            'type': 'lag',
+        }
+    )
+    mgmt_only = forms.NullBooleanField(
+        required=False,
+        widget=BulkEditNullBooleanSelect,
+        label='Management only'
+    )
+    mark_connected = forms.NullBooleanField(
+        required=False,
+        widget=BulkEditNullBooleanSelect
+    )
+    untagged_vlan = DynamicModelChoiceField(
+        queryset=VLAN.objects.all(),
+        required=False
+    )
+    tagged_vlans = DynamicModelMultipleChoiceField(
+        queryset=VLAN.objects.all(),
+        required=False
+    )
+
+    class Meta:
+        nullable_fields = [
+            'label', 'parent', 'lag', 'mac_address', 'mtu', 'description', 'mode', 'untagged_vlan', 'tagged_vlans'
+        ]
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        if 'device' in self.initial:
+            device = Device.objects.filter(pk=self.initial['device']).first()
+
+            # Restrict parent/LAG interface assignment by device
+            self.fields['parent'].widget.add_query_param('device_id', device.pk)
+            self.fields['lag'].widget.add_query_param('device_id', device.pk)
+
+            # Limit VLAN choices by device
+            self.fields['untagged_vlan'].widget.add_query_param('available_on_device', device.pk)
+            self.fields['tagged_vlans'].widget.add_query_param('available_on_device', device.pk)
+
+        else:
+            # See #4523
+            if 'pk' in self.initial:
+                site = None
+                interfaces = Interface.objects.filter(pk__in=self.initial['pk']).prefetch_related('device__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.device.site
+                    elif interface.device.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)
+
+            self.fields['parent'].choices = ()
+            self.fields['parent'].widget.attrs['disabled'] = True
+            self.fields['lag'].choices = ()
+            self.fields['lag'].widget.attrs['disabled'] = True
+
+    def clean(self):
+        super().clean()
+
+        # Untagged interfaces cannot be assigned tagged VLANs
+        if self.cleaned_data['mode'] == InterfaceModeChoices.MODE_ACCESS and self.cleaned_data['tagged_vlans']:
+            raise forms.ValidationError({
+                'mode': "An access interface cannot have tagged VLANs assigned."
+            })
+
+        # Remove all tagged VLAN assignments from "tagged all" interfaces
+        elif self.cleaned_data['mode'] == InterfaceModeChoices.MODE_TAGGED_ALL:
+            self.cleaned_data['tagged_vlans'] = []
+
+
+class FrontPortBulkEditForm(
+    form_from_model(FrontPort, ['label', 'type', 'color', 'mark_connected', 'description']),
+    BootstrapMixin,
+    AddRemoveTagsForm,
+    CustomFieldModelBulkEditForm
+):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=FrontPort.objects.all(),
+        widget=forms.MultipleHiddenInput()
+    )
+
+    class Meta:
+        nullable_fields = ['label', 'description']
+
+
+class RearPortBulkEditForm(
+    form_from_model(RearPort, ['label', 'type', 'color', 'mark_connected', 'description']),
+    BootstrapMixin,
+    AddRemoveTagsForm,
+    CustomFieldModelBulkEditForm
+):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=RearPort.objects.all(),
+        widget=forms.MultipleHiddenInput()
+    )
+
+    class Meta:
+        nullable_fields = ['label', 'description']
+
+
+class DeviceBayBulkEditForm(
+    form_from_model(DeviceBay, ['label', 'description']),
+    BootstrapMixin,
+    AddRemoveTagsForm,
+    CustomFieldModelBulkEditForm
+):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=DeviceBay.objects.all(),
+        widget=forms.MultipleHiddenInput()
+    )
+
+    class Meta:
+        nullable_fields = ['label', 'description']
+
+
+class InventoryItemBulkEditForm(
+    form_from_model(InventoryItem, ['label', 'manufacturer', 'part_id', 'description']),
+    BootstrapMixin,
+    AddRemoveTagsForm,
+    CustomFieldModelBulkEditForm
+):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=InventoryItem.objects.all(),
+        widget=forms.MultipleHiddenInput()
+    )
+    manufacturer = DynamicModelChoiceField(
+        queryset=Manufacturer.objects.all(),
+        required=False
+    )
+
+    class Meta:
+        nullable_fields = ['label', 'manufacturer', 'part_id', 'description']

+ 976 - 0
netbox/dcim/forms/bulk_import.py

@@ -0,0 +1,976 @@
+from django import forms
+from django.contrib.contenttypes.models import ContentType
+from django.contrib.postgres.forms.array import SimpleArrayField
+from django.core.exceptions import ObjectDoesNotExist
+from django.utils.safestring import mark_safe
+
+from dcim.choices import *
+from dcim.constants import *
+from dcim.models import *
+from extras.forms import CustomFieldModelCSVForm
+from tenancy.models import Tenant
+from utilities.forms import CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVTypedChoiceField, SlugField
+from virtualization.models import Cluster
+
+__all__ = (
+    'CableCSVForm',
+    'ChildDeviceCSVForm',
+    'ConsolePortCSVForm',
+    'ConsoleServerPortCSVForm',
+    'DeviceBayCSVForm',
+    'DeviceCSVForm',
+    'DeviceRoleCSVForm',
+    'FrontPortCSVForm',
+    'InterfaceCSVForm',
+    'InventoryItemCSVForm',
+    'LocationCSVForm',
+    'ManufacturerCSVForm',
+    'PlatformCSVForm',
+    'PowerFeedCSVForm',
+    'PowerOutletCSVForm',
+    'PowerPanelCSVForm',
+    'PowerPortCSVForm',
+    'RackCSVForm',
+    'RackReservationCSVForm',
+    'RackRoleCSVForm',
+    'RearPortCSVForm',
+    'RegionCSVForm',
+    'SiteCSVForm',
+    'SiteGroupCSVForm',
+    'VirtualChassisCSVForm',
+)
+
+
+class RegionCSVForm(CustomFieldModelCSVForm):
+    parent = CSVModelChoiceField(
+        queryset=Region.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text='Name of parent region'
+    )
+
+    class Meta:
+        model = Region
+        fields = ('name', 'slug', 'parent', 'description')
+
+
+class SiteGroupCSVForm(CustomFieldModelCSVForm):
+    parent = CSVModelChoiceField(
+        queryset=SiteGroup.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text='Name of parent site group'
+    )
+
+    class Meta:
+        model = SiteGroup
+        fields = ('name', 'slug', 'parent', 'description')
+
+
+class SiteCSVForm(CustomFieldModelCSVForm):
+    status = CSVChoiceField(
+        choices=SiteStatusChoices,
+        required=False,
+        help_text='Operational status'
+    )
+    region = CSVModelChoiceField(
+        queryset=Region.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text='Assigned region'
+    )
+    group = CSVModelChoiceField(
+        queryset=SiteGroup.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text='Assigned group'
+    )
+    tenant = CSVModelChoiceField(
+        queryset=Tenant.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text='Assigned tenant'
+    )
+
+    class Meta:
+        model = Site
+        fields = (
+            'name', 'slug', 'status', 'region', 'group', 'tenant', 'facility', 'asn', 'time_zone', 'description',
+            'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone',
+            'contact_email', 'comments',
+        )
+        help_texts = {
+            'time_zone': mark_safe(
+                'Time zone (<a href="https://en.wikipedia.org/wiki/List_of_tz_database_time_zones">available options</a>)'
+            )
+        }
+
+
+class LocationCSVForm(CustomFieldModelCSVForm):
+    site = CSVModelChoiceField(
+        queryset=Site.objects.all(),
+        to_field_name='name',
+        help_text='Assigned site'
+    )
+    parent = CSVModelChoiceField(
+        queryset=Location.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text='Parent location',
+        error_messages={
+            'invalid_choice': 'Location not found.',
+        }
+    )
+
+    class Meta:
+        model = Location
+        fields = ('site', 'parent', 'name', 'slug', 'description')
+
+
+class RackRoleCSVForm(CustomFieldModelCSVForm):
+    slug = SlugField()
+
+    class Meta:
+        model = RackRole
+        fields = ('name', 'slug', 'color', 'description')
+        help_texts = {
+            'color': mark_safe('RGB color in hexadecimal (e.g. <code>00ff00</code>)'),
+        }
+
+
+class RackCSVForm(CustomFieldModelCSVForm):
+    site = CSVModelChoiceField(
+        queryset=Site.objects.all(),
+        to_field_name='name'
+    )
+    location = CSVModelChoiceField(
+        queryset=Location.objects.all(),
+        required=False,
+        to_field_name='name'
+    )
+    tenant = CSVModelChoiceField(
+        queryset=Tenant.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text='Name of assigned tenant'
+    )
+    status = CSVChoiceField(
+        choices=RackStatusChoices,
+        required=False,
+        help_text='Operational status'
+    )
+    role = CSVModelChoiceField(
+        queryset=RackRole.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text='Name of assigned role'
+    )
+    type = CSVChoiceField(
+        choices=RackTypeChoices,
+        required=False,
+        help_text='Rack type'
+    )
+    width = forms.ChoiceField(
+        choices=RackWidthChoices,
+        help_text='Rail-to-rail width (in inches)'
+    )
+    outer_unit = CSVChoiceField(
+        choices=RackDimensionUnitChoices,
+        required=False,
+        help_text='Unit for outer dimensions'
+    )
+
+    class Meta:
+        model = Rack
+        fields = (
+            'site', 'location', 'name', 'facility_id', 'tenant', 'status', 'role', 'type', 'serial', 'asset_tag',
+            'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'comments',
+        )
+
+    def __init__(self, data=None, *args, **kwargs):
+        super().__init__(data, *args, **kwargs)
+
+        if data:
+
+            # Limit location queryset by assigned site
+            params = {f"site__{self.fields['site'].to_field_name}": data.get('site')}
+            self.fields['location'].queryset = self.fields['location'].queryset.filter(**params)
+
+
+class RackReservationCSVForm(CustomFieldModelCSVForm):
+    site = CSVModelChoiceField(
+        queryset=Site.objects.all(),
+        to_field_name='name',
+        help_text='Parent site'
+    )
+    location = CSVModelChoiceField(
+        queryset=Location.objects.all(),
+        to_field_name='name',
+        required=False,
+        help_text="Rack's location (if any)"
+    )
+    rack = CSVModelChoiceField(
+        queryset=Rack.objects.all(),
+        to_field_name='name',
+        help_text='Rack'
+    )
+    units = SimpleArrayField(
+        base_field=forms.IntegerField(),
+        required=True,
+        help_text='Comma-separated list of individual unit numbers'
+    )
+    tenant = CSVModelChoiceField(
+        queryset=Tenant.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text='Assigned tenant'
+    )
+
+    class Meta:
+        model = RackReservation
+        fields = ('site', 'location', 'rack', 'units', 'tenant', 'description')
+
+    def __init__(self, data=None, *args, **kwargs):
+        super().__init__(data, *args, **kwargs)
+
+        if data:
+
+            # Limit location queryset by assigned site
+            params = {f"site__{self.fields['site'].to_field_name}": data.get('site')}
+            self.fields['location'].queryset = self.fields['location'].queryset.filter(**params)
+
+            # Limit rack queryset by assigned site and group
+            params = {
+                f"site__{self.fields['site'].to_field_name}": data.get('site'),
+                f"location__{self.fields['location'].to_field_name}": data.get('location'),
+            }
+            self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params)
+
+
+class ManufacturerCSVForm(CustomFieldModelCSVForm):
+
+    class Meta:
+        model = Manufacturer
+        fields = ('name', 'slug', 'description')
+
+
+class DeviceRoleCSVForm(CustomFieldModelCSVForm):
+    slug = SlugField()
+
+    class Meta:
+        model = DeviceRole
+        fields = ('name', 'slug', 'color', 'vm_role', 'description')
+        help_texts = {
+            'color': mark_safe('RGB color in hexadecimal (e.g. <code>00ff00</code>)'),
+        }
+
+
+class PlatformCSVForm(CustomFieldModelCSVForm):
+    slug = SlugField()
+    manufacturer = CSVModelChoiceField(
+        queryset=Manufacturer.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text='Limit platform assignments to this manufacturer'
+    )
+
+    class Meta:
+        model = Platform
+        fields = ('name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description')
+
+
+class BaseDeviceCSVForm(CustomFieldModelCSVForm):
+    device_role = CSVModelChoiceField(
+        queryset=DeviceRole.objects.all(),
+        to_field_name='name',
+        help_text='Assigned role'
+    )
+    tenant = CSVModelChoiceField(
+        queryset=Tenant.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text='Assigned tenant'
+    )
+    manufacturer = CSVModelChoiceField(
+        queryset=Manufacturer.objects.all(),
+        to_field_name='name',
+        help_text='Device type manufacturer'
+    )
+    device_type = CSVModelChoiceField(
+        queryset=DeviceType.objects.all(),
+        to_field_name='model',
+        help_text='Device type model'
+    )
+    platform = CSVModelChoiceField(
+        queryset=Platform.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text='Assigned platform'
+    )
+    status = CSVChoiceField(
+        choices=DeviceStatusChoices,
+        help_text='Operational status'
+    )
+    virtual_chassis = CSVModelChoiceField(
+        queryset=VirtualChassis.objects.all(),
+        to_field_name='name',
+        required=False,
+        help_text='Virtual chassis'
+    )
+    cluster = CSVModelChoiceField(
+        queryset=Cluster.objects.all(),
+        to_field_name='name',
+        required=False,
+        help_text='Virtualization cluster'
+    )
+
+    class Meta:
+        fields = []
+        model = Device
+        help_texts = {
+            'vc_position': 'Virtual chassis position',
+            'vc_priority': 'Virtual chassis priority',
+        }
+
+    def __init__(self, data=None, *args, **kwargs):
+        super().__init__(data, *args, **kwargs)
+
+        if data:
+
+            # Limit device type queryset by manufacturer
+            params = {f"manufacturer__{self.fields['manufacturer'].to_field_name}": data.get('manufacturer')}
+            self.fields['device_type'].queryset = self.fields['device_type'].queryset.filter(**params)
+
+
+class DeviceCSVForm(BaseDeviceCSVForm):
+    site = CSVModelChoiceField(
+        queryset=Site.objects.all(),
+        to_field_name='name',
+        help_text='Assigned site'
+    )
+    location = CSVModelChoiceField(
+        queryset=Location.objects.all(),
+        to_field_name='name',
+        required=False,
+        help_text="Assigned location (if any)"
+    )
+    rack = CSVModelChoiceField(
+        queryset=Rack.objects.all(),
+        to_field_name='name',
+        required=False,
+        help_text="Assigned rack (if any)"
+    )
+    face = CSVChoiceField(
+        choices=DeviceFaceChoices,
+        required=False,
+        help_text='Mounted rack face'
+    )
+
+    class Meta(BaseDeviceCSVForm.Meta):
+        fields = [
+            'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status',
+            'site', 'location', 'rack', 'position', 'face', 'virtual_chassis', 'vc_position', 'vc_priority', 'cluster',
+            'comments',
+        ]
+
+    def __init__(self, data=None, *args, **kwargs):
+        super().__init__(data, *args, **kwargs)
+
+        if data:
+
+            # Limit location queryset by assigned site
+            params = {f"site__{self.fields['site'].to_field_name}": data.get('site')}
+            self.fields['location'].queryset = self.fields['location'].queryset.filter(**params)
+
+            # Limit rack queryset by assigned site and group
+            params = {
+                f"site__{self.fields['site'].to_field_name}": data.get('site'),
+                f"location__{self.fields['location'].to_field_name}": data.get('location'),
+            }
+            self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params)
+
+
+class ChildDeviceCSVForm(BaseDeviceCSVForm):
+    parent = CSVModelChoiceField(
+        queryset=Device.objects.all(),
+        to_field_name='name',
+        help_text='Parent device'
+    )
+    device_bay = CSVModelChoiceField(
+        queryset=DeviceBay.objects.all(),
+        to_field_name='name',
+        help_text='Device bay in which this device is installed'
+    )
+
+    class Meta(BaseDeviceCSVForm.Meta):
+        fields = [
+            'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status',
+            'parent', 'device_bay', 'virtual_chassis', 'vc_position', 'vc_priority', 'cluster', 'comments',
+        ]
+
+    def __init__(self, data=None, *args, **kwargs):
+        super().__init__(data, *args, **kwargs)
+
+        if data:
+
+            # Limit device bay queryset by parent device
+            params = {f"device__{self.fields['parent'].to_field_name}": data.get('parent')}
+            self.fields['device_bay'].queryset = self.fields['device_bay'].queryset.filter(**params)
+
+    def clean(self):
+        super().clean()
+
+        # Set parent_bay reverse relationship
+        device_bay = self.cleaned_data.get('device_bay')
+        if device_bay:
+            self.instance.parent_bay = device_bay
+
+        # Inherit site and rack from parent device
+        parent = self.cleaned_data.get('parent')
+        if parent:
+            self.instance.site = parent.site
+            self.instance.rack = parent.rack
+
+
+#
+# Device components
+#
+
+class ConsolePortCSVForm(CustomFieldModelCSVForm):
+    device = CSVModelChoiceField(
+        queryset=Device.objects.all(),
+        to_field_name='name'
+    )
+    type = CSVChoiceField(
+        choices=ConsolePortTypeChoices,
+        required=False,
+        help_text='Port type'
+    )
+    speed = CSVTypedChoiceField(
+        choices=ConsolePortSpeedChoices,
+        coerce=int,
+        empty_value=None,
+        required=False,
+        help_text='Port speed in bps'
+    )
+
+    class Meta:
+        model = ConsolePort
+        fields = ('device', 'name', 'label', 'type', 'speed', 'mark_connected', 'description')
+
+
+class ConsoleServerPortCSVForm(CustomFieldModelCSVForm):
+    device = CSVModelChoiceField(
+        queryset=Device.objects.all(),
+        to_field_name='name'
+    )
+    type = CSVChoiceField(
+        choices=ConsolePortTypeChoices,
+        required=False,
+        help_text='Port type'
+    )
+    speed = CSVTypedChoiceField(
+        choices=ConsolePortSpeedChoices,
+        coerce=int,
+        empty_value=None,
+        required=False,
+        help_text='Port speed in bps'
+    )
+
+    class Meta:
+        model = ConsoleServerPort
+        fields = ('device', 'name', 'label', 'type', 'speed', 'mark_connected', 'description')
+
+
+class PowerPortCSVForm(CustomFieldModelCSVForm):
+    device = CSVModelChoiceField(
+        queryset=Device.objects.all(),
+        to_field_name='name'
+    )
+    type = CSVChoiceField(
+        choices=PowerPortTypeChoices,
+        required=False,
+        help_text='Port type'
+    )
+
+    class Meta:
+        model = PowerPort
+        fields = (
+            'device', 'name', 'label', 'type', 'mark_connected', 'maximum_draw', 'allocated_draw', 'description',
+        )
+
+
+class PowerOutletCSVForm(CustomFieldModelCSVForm):
+    device = CSVModelChoiceField(
+        queryset=Device.objects.all(),
+        to_field_name='name'
+    )
+    type = CSVChoiceField(
+        choices=PowerOutletTypeChoices,
+        required=False,
+        help_text='Outlet type'
+    )
+    power_port = CSVModelChoiceField(
+        queryset=PowerPort.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text='Local power port which feeds this outlet'
+    )
+    feed_leg = CSVChoiceField(
+        choices=PowerOutletFeedLegChoices,
+        required=False,
+        help_text='Electrical phase (for three-phase circuits)'
+    )
+
+    class Meta:
+        model = PowerOutlet
+        fields = ('device', 'name', 'label', 'type', 'mark_connected', 'power_port', 'feed_leg', 'description')
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        # Limit PowerPort choices to those belonging to this device (or VC master)
+        if self.is_bound:
+            try:
+                device = self.fields['device'].to_python(self.data['device'])
+            except forms.ValidationError:
+                device = None
+        else:
+            try:
+                device = self.instance.device
+            except Device.DoesNotExist:
+                device = None
+
+        if device:
+            self.fields['power_port'].queryset = PowerPort.objects.filter(
+                device__in=[device, device.get_vc_master()]
+            )
+        else:
+            self.fields['power_port'].queryset = PowerPort.objects.none()
+
+
+class InterfaceCSVForm(CustomFieldModelCSVForm):
+    device = CSVModelChoiceField(
+        queryset=Device.objects.all(),
+        to_field_name='name'
+    )
+    parent = CSVModelChoiceField(
+        queryset=Interface.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text='Parent interface'
+    )
+    lag = CSVModelChoiceField(
+        queryset=Interface.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text='Parent LAG interface'
+    )
+    type = CSVChoiceField(
+        choices=InterfaceTypeChoices,
+        help_text='Physical medium'
+    )
+    mode = CSVChoiceField(
+        choices=InterfaceModeChoices,
+        required=False,
+        help_text='IEEE 802.1Q operational mode (for L2 interfaces)'
+    )
+
+    class Meta:
+        model = Interface
+        fields = (
+            'device', 'name', 'label', 'parent', 'lag', 'type', 'enabled', 'mark_connected', 'mac_address', 'mtu',
+            'mgmt_only', 'description', 'mode',
+        )
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        # Limit LAG choices to interfaces belonging to this device (or virtual chassis)
+        device = None
+        if self.is_bound and 'device' in self.data:
+            try:
+                device = self.fields['device'].to_python(self.data['device'])
+            except forms.ValidationError:
+                pass
+        if device and device.virtual_chassis:
+            self.fields['lag'].queryset = Interface.objects.filter(
+                Q(device=device) | Q(device__virtual_chassis=device.virtual_chassis),
+                type=InterfaceTypeChoices.TYPE_LAG
+            )
+            self.fields['parent'].queryset = Interface.objects.filter(
+                Q(device=device) | Q(device__virtual_chassis=device.virtual_chassis)
+            )
+        elif device:
+            self.fields['lag'].queryset = Interface.objects.filter(
+                device=device,
+                type=InterfaceTypeChoices.TYPE_LAG
+            )
+            self.fields['parent'].queryset = Interface.objects.filter(device=device)
+        else:
+            self.fields['lag'].queryset = Interface.objects.none()
+            self.fields['parent'].queryset = Interface.objects.none()
+
+    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 FrontPortCSVForm(CustomFieldModelCSVForm):
+    device = CSVModelChoiceField(
+        queryset=Device.objects.all(),
+        to_field_name='name'
+    )
+    rear_port = CSVModelChoiceField(
+        queryset=RearPort.objects.all(),
+        to_field_name='name',
+        help_text='Corresponding rear port'
+    )
+    type = CSVChoiceField(
+        choices=PortTypeChoices,
+        help_text='Physical medium classification'
+    )
+
+    class Meta:
+        model = FrontPort
+        fields = (
+            'device', 'name', 'label', 'type', 'color', 'mark_connected', 'rear_port', 'rear_port_position',
+            'description',
+        )
+        help_texts = {
+            'rear_port_position': 'Mapped position on corresponding rear port',
+        }
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        # Limit RearPort choices to those belonging to this device (or VC master)
+        if self.is_bound:
+            try:
+                device = self.fields['device'].to_python(self.data['device'])
+            except forms.ValidationError:
+                device = None
+        else:
+            try:
+                device = self.instance.device
+            except Device.DoesNotExist:
+                device = None
+
+        if device:
+            self.fields['rear_port'].queryset = RearPort.objects.filter(
+                device__in=[device, device.get_vc_master()]
+            )
+        else:
+            self.fields['rear_port'].queryset = RearPort.objects.none()
+
+
+class RearPortCSVForm(CustomFieldModelCSVForm):
+    device = CSVModelChoiceField(
+        queryset=Device.objects.all(),
+        to_field_name='name'
+    )
+    type = CSVChoiceField(
+        help_text='Physical medium classification',
+        choices=PortTypeChoices,
+    )
+
+    class Meta:
+        model = RearPort
+        fields = ('device', 'name', 'label', 'type', 'color', 'mark_connected', 'positions', 'description')
+        help_texts = {
+            'positions': 'Number of front ports which may be mapped'
+        }
+
+
+class DeviceBayCSVForm(CustomFieldModelCSVForm):
+    device = CSVModelChoiceField(
+        queryset=Device.objects.all(),
+        to_field_name='name'
+    )
+    installed_device = CSVModelChoiceField(
+        queryset=Device.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text='Child device installed within this bay',
+        error_messages={
+            'invalid_choice': 'Child device not found.',
+        }
+    )
+
+    class Meta:
+        model = DeviceBay
+        fields = ('device', 'name', 'label', 'installed_device', 'description')
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        # Limit installed device choices to devices of the correct type and location
+        if self.is_bound:
+            try:
+                device = self.fields['device'].to_python(self.data['device'])
+            except forms.ValidationError:
+                device = None
+        else:
+            try:
+                device = self.instance.device
+            except Device.DoesNotExist:
+                device = None
+
+        if device:
+            self.fields['installed_device'].queryset = Device.objects.filter(
+                site=device.site,
+                rack=device.rack,
+                parent_bay__isnull=True,
+                device_type__u_height=0,
+                device_type__subdevice_role=SubdeviceRoleChoices.ROLE_CHILD
+            ).exclude(pk=device.pk)
+        else:
+            self.fields['installed_device'].queryset = Interface.objects.none()
+
+
+class InventoryItemCSVForm(CustomFieldModelCSVForm):
+    device = CSVModelChoiceField(
+        queryset=Device.objects.all(),
+        to_field_name='name'
+    )
+    manufacturer = CSVModelChoiceField(
+        queryset=Manufacturer.objects.all(),
+        to_field_name='name',
+        required=False
+    )
+    parent = CSVModelChoiceField(
+        queryset=Device.objects.all(),
+        to_field_name='name',
+        required=False,
+        help_text='Parent inventory item'
+    )
+
+    class Meta:
+        model = InventoryItem
+        fields = (
+            'device', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', 'description',
+        )
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        # Limit parent choices to inventory items belonging to this device
+        device = None
+        if self.is_bound and 'device' in self.data:
+            try:
+                device = self.fields['device'].to_python(self.data['device'])
+            except forms.ValidationError:
+                pass
+        if device:
+            self.fields['parent'].queryset = InventoryItem.objects.filter(device=device)
+        else:
+            self.fields['parent'].queryset = InventoryItem.objects.none()
+
+
+class CableCSVForm(CustomFieldModelCSVForm):
+    # Termination A
+    side_a_device = CSVModelChoiceField(
+        queryset=Device.objects.all(),
+        to_field_name='name',
+        help_text='Side A device'
+    )
+    side_a_type = CSVContentTypeField(
+        queryset=ContentType.objects.all(),
+        limit_choices_to=CABLE_TERMINATION_MODELS,
+        help_text='Side A type'
+    )
+    side_a_name = forms.CharField(
+        help_text='Side A component name'
+    )
+
+    # Termination B
+    side_b_device = CSVModelChoiceField(
+        queryset=Device.objects.all(),
+        to_field_name='name',
+        help_text='Side B device'
+    )
+    side_b_type = CSVContentTypeField(
+        queryset=ContentType.objects.all(),
+        limit_choices_to=CABLE_TERMINATION_MODELS,
+        help_text='Side B type'
+    )
+    side_b_name = forms.CharField(
+        help_text='Side B component name'
+    )
+
+    # Cable attributes
+    status = CSVChoiceField(
+        choices=CableStatusChoices,
+        required=False,
+        help_text='Connection status'
+    )
+    type = CSVChoiceField(
+        choices=CableTypeChoices,
+        required=False,
+        help_text='Physical medium classification'
+    )
+    length_unit = CSVChoiceField(
+        choices=CableLengthUnitChoices,
+        required=False,
+        help_text='Length unit'
+    )
+
+    class Meta:
+        model = Cable
+        fields = [
+            'side_a_device', 'side_a_type', 'side_a_name', 'side_b_device', 'side_b_type', 'side_b_name', 'type',
+            'status', 'label', 'color', 'length', 'length_unit',
+        ]
+        help_texts = {
+            'color': mark_safe('RGB color in hexadecimal (e.g. <code>00ff00</code>)'),
+        }
+
+    def _clean_side(self, side):
+        """
+        Derive a Cable's A/B termination objects.
+
+        :param side: 'a' or 'b'
+        """
+        assert side in 'ab', f"Invalid side designation: {side}"
+
+        device = self.cleaned_data.get(f'side_{side}_device')
+        content_type = self.cleaned_data.get(f'side_{side}_type')
+        name = self.cleaned_data.get(f'side_{side}_name')
+        if not device or not content_type or not name:
+            return None
+
+        model = content_type.model_class()
+        try:
+            termination_object = model.objects.get(device=device, name=name)
+            if termination_object.cable is not None:
+                raise forms.ValidationError(f"Side {side.upper()}: {device} {termination_object} is already connected")
+        except ObjectDoesNotExist:
+            raise forms.ValidationError(f"{side.upper()} side termination not found: {device} {name}")
+
+        setattr(self.instance, f'termination_{side}', termination_object)
+        return termination_object
+
+    def clean_side_a_name(self):
+        return self._clean_side('a')
+
+    def clean_side_b_name(self):
+        return self._clean_side('b')
+
+    def clean_length_unit(self):
+        # Avoid trying to save as NULL
+        length_unit = self.cleaned_data.get('length_unit', None)
+        return length_unit if length_unit is not None else ''
+
+
+class VirtualChassisCSVForm(CustomFieldModelCSVForm):
+    master = CSVModelChoiceField(
+        queryset=Device.objects.all(),
+        to_field_name='name',
+        required=False,
+        help_text='Master device'
+    )
+
+    class Meta:
+        model = VirtualChassis
+        fields = ('name', 'domain', 'master')
+
+
+class PowerPanelCSVForm(CustomFieldModelCSVForm):
+    site = CSVModelChoiceField(
+        queryset=Site.objects.all(),
+        to_field_name='name',
+        help_text='Name of parent site'
+    )
+    location = CSVModelChoiceField(
+        queryset=Location.objects.all(),
+        required=False,
+        to_field_name='name'
+    )
+
+    class Meta:
+        model = PowerPanel
+        fields = ('site', 'location', 'name')
+
+    def __init__(self, data=None, *args, **kwargs):
+        super().__init__(data, *args, **kwargs)
+
+        if data:
+
+            # Limit group queryset by assigned site
+            params = {f"site__{self.fields['site'].to_field_name}": data.get('site')}
+            self.fields['location'].queryset = self.fields['location'].queryset.filter(**params)
+
+
+class PowerFeedCSVForm(CustomFieldModelCSVForm):
+    site = CSVModelChoiceField(
+        queryset=Site.objects.all(),
+        to_field_name='name',
+        help_text='Assigned site'
+    )
+    power_panel = CSVModelChoiceField(
+        queryset=PowerPanel.objects.all(),
+        to_field_name='name',
+        help_text='Upstream power panel'
+    )
+    location = CSVModelChoiceField(
+        queryset=Location.objects.all(),
+        to_field_name='name',
+        required=False,
+        help_text="Rack's location (if any)"
+    )
+    rack = CSVModelChoiceField(
+        queryset=Rack.objects.all(),
+        to_field_name='name',
+        required=False,
+        help_text='Rack'
+    )
+    status = CSVChoiceField(
+        choices=PowerFeedStatusChoices,
+        required=False,
+        help_text='Operational status'
+    )
+    type = CSVChoiceField(
+        choices=PowerFeedTypeChoices,
+        required=False,
+        help_text='Primary or redundant'
+    )
+    supply = CSVChoiceField(
+        choices=PowerFeedSupplyChoices,
+        required=False,
+        help_text='Supply type (AC/DC)'
+    )
+    phase = CSVChoiceField(
+        choices=PowerFeedPhaseChoices,
+        required=False,
+        help_text='Single or three-phase'
+    )
+
+    class Meta:
+        model = PowerFeed
+        fields = (
+            'site', 'power_panel', 'location', 'rack', 'name', 'status', 'type', 'mark_connected', 'supply', 'phase',
+            'voltage', 'amperage', 'max_utilization', 'comments',
+        )
+
+    def __init__(self, data=None, *args, **kwargs):
+        super().__init__(data, *args, **kwargs)
+
+        if data:
+
+            # Limit power_panel queryset by site
+            params = {f"site__{self.fields['site'].to_field_name}": data.get('site')}
+            self.fields['power_panel'].queryset = self.fields['power_panel'].queryset.filter(**params)
+
+            # Limit location queryset by site
+            params = {f"site__{self.fields['site'].to_field_name}": data.get('site')}
+            self.fields['location'].queryset = self.fields['location'].queryset.filter(**params)
+
+            # Limit rack queryset by site and group
+            params = {
+                f"site__{self.fields['site'].to_field_name}": data.get('site'),
+                f"location__{self.fields['location'].to_field_name}": data.get('location'),
+            }
+            self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params)

+ 49 - 0
netbox/dcim/forms/common.py

@@ -0,0 +1,49 @@
+from django import forms
+
+from dcim.choices import *
+from dcim.constants import *
+
+__all__ = (
+    'InterfaceCommonForm',
+)
+
+
+class InterfaceCommonForm(forms.Form):
+    mac_address = forms.CharField(
+        empty_value=None,
+        required=False,
+        label='MAC address'
+    )
+    mtu = forms.IntegerField(
+        required=False,
+        min_value=INTERFACE_MTU_MIN,
+        max_value=INTERFACE_MTU_MAX,
+        label='MTU'
+    )
+
+    def clean(self):
+        super().clean()
+
+        parent_field = 'device' if 'device' in self.cleaned_data else 'virtual_machine'
+        tagged_vlans = self.cleaned_data.get('tagged_vlans')
+
+        # Untagged interfaces cannot be assigned tagged VLANs
+        if self.cleaned_data['mode'] == InterfaceModeChoices.MODE_ACCESS and tagged_vlans:
+            raise forms.ValidationError({
+                'mode': "An access interface cannot have tagged VLANs assigned."
+            })
+
+        # Remove all tagged VLAN assignments from "tagged all" interfaces
+        elif self.cleaned_data['mode'] == InterfaceModeChoices.MODE_TAGGED_ALL:
+            self.cleaned_data['tagged_vlans'] = []
+
+        # Validate tagged VLANs; must be a global VLAN or in the same site
+        elif self.cleaned_data['mode'] == InterfaceModeChoices.MODE_TAGGED and tagged_vlans:
+            valid_sites = [None, self.cleaned_data[parent_field].site]
+            invalid_vlans = [str(v) for v in tagged_vlans if v.site not in valid_sites]
+
+            if invalid_vlans:
+                raise forms.ValidationError({
+                    'tagged_vlans': f"The tagged VLANs ({', '.join(invalid_vlans)}) must belong to the same site as "
+                                    f"the interface's parent device/VM, or they must be global"
+                })

+ 289 - 0
netbox/dcim/forms/connections.py

@@ -0,0 +1,289 @@
+from circuits.models import Circuit, CircuitTermination, Provider
+from dcim.models import *
+from extras.forms import CustomFieldModelForm
+from extras.models import Tag
+from utilities.forms import BootstrapMixin, DynamicModelChoiceField, DynamicModelMultipleChoiceField, StaticSelect
+
+__all__ = (
+    'ConnectCableToCircuitTerminationForm',
+    'ConnectCableToConsolePortForm',
+    'ConnectCableToConsoleServerPortForm',
+    'ConnectCableToFrontPortForm',
+    'ConnectCableToInterfaceForm',
+    'ConnectCableToPowerFeedForm',
+    'ConnectCableToPowerPortForm',
+    'ConnectCableToPowerOutletForm',
+    'ConnectCableToRearPortForm',
+)
+
+
+class ConnectCableToDeviceForm(BootstrapMixin, CustomFieldModelForm):
+    """
+    Base form for connecting a Cable to a Device component
+    """
+    termination_b_region = DynamicModelChoiceField(
+        queryset=Region.objects.all(),
+        label='Region',
+        required=False
+    )
+    termination_b_site_group = DynamicModelChoiceField(
+        queryset=SiteGroup.objects.all(),
+        label='Site group',
+        required=False
+    )
+    termination_b_site = DynamicModelChoiceField(
+        queryset=Site.objects.all(),
+        label='Site',
+        required=False,
+        query_params={
+            'region_id': '$termination_b_region',
+            'group_id': '$termination_b_site_group',
+        }
+    )
+    termination_b_location = DynamicModelChoiceField(
+        queryset=Location.objects.all(),
+        label='Location',
+        required=False,
+        null_option='None',
+        query_params={
+            'site_id': '$termination_b_site'
+        }
+    )
+    termination_b_rack = DynamicModelChoiceField(
+        queryset=Rack.objects.all(),
+        label='Rack',
+        required=False,
+        null_option='None',
+        query_params={
+            'site_id': '$termination_b_site',
+            'location_id': '$termination_b_location',
+        }
+    )
+    termination_b_device = DynamicModelChoiceField(
+        queryset=Device.objects.all(),
+        label='Device',
+        required=False,
+        query_params={
+            'site_id': '$termination_b_site',
+            'location_id': '$termination_b_location',
+            'rack_id': '$termination_b_rack',
+        }
+    )
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
+        required=False
+    )
+
+    class Meta:
+        model = Cable
+        fields = [
+            'termination_b_region', 'termination_b_site', 'termination_b_rack', 'termination_b_device',
+            'termination_b_id', 'type', 'status', 'label', 'color', 'length', 'length_unit', 'tags',
+        ]
+        widgets = {
+            'status': StaticSelect,
+            'type': StaticSelect,
+            'length_unit': StaticSelect,
+        }
+
+    def clean_termination_b_id(self):
+        # Return the PK rather than the object
+        return getattr(self.cleaned_data['termination_b_id'], 'pk', None)
+
+
+class ConnectCableToConsolePortForm(ConnectCableToDeviceForm):
+    termination_b_id = DynamicModelChoiceField(
+        queryset=ConsolePort.objects.all(),
+        label='Name',
+        disabled_indicator='_occupied',
+        query_params={
+            'device_id': '$termination_b_device'
+        }
+    )
+
+
+class ConnectCableToConsoleServerPortForm(ConnectCableToDeviceForm):
+    termination_b_id = DynamicModelChoiceField(
+        queryset=ConsoleServerPort.objects.all(),
+        label='Name',
+        disabled_indicator='_occupied',
+        query_params={
+            'device_id': '$termination_b_device'
+        }
+    )
+
+
+class ConnectCableToPowerPortForm(ConnectCableToDeviceForm):
+    termination_b_id = DynamicModelChoiceField(
+        queryset=PowerPort.objects.all(),
+        label='Name',
+        disabled_indicator='_occupied',
+        query_params={
+            'device_id': '$termination_b_device'
+        }
+    )
+
+
+class ConnectCableToPowerOutletForm(ConnectCableToDeviceForm):
+    termination_b_id = DynamicModelChoiceField(
+        queryset=PowerOutlet.objects.all(),
+        label='Name',
+        disabled_indicator='_occupied',
+        query_params={
+            'device_id': '$termination_b_device'
+        }
+    )
+
+
+class ConnectCableToInterfaceForm(ConnectCableToDeviceForm):
+    termination_b_id = DynamicModelChoiceField(
+        queryset=Interface.objects.all(),
+        label='Name',
+        disabled_indicator='_occupied',
+        query_params={
+            'device_id': '$termination_b_device',
+            'kind': 'physical',
+        }
+    )
+
+
+class ConnectCableToFrontPortForm(ConnectCableToDeviceForm):
+    termination_b_id = DynamicModelChoiceField(
+        queryset=FrontPort.objects.all(),
+        label='Name',
+        disabled_indicator='_occupied',
+        query_params={
+            'device_id': '$termination_b_device'
+        }
+    )
+
+
+class ConnectCableToRearPortForm(ConnectCableToDeviceForm):
+    termination_b_id = DynamicModelChoiceField(
+        queryset=RearPort.objects.all(),
+        label='Name',
+        disabled_indicator='_occupied',
+        query_params={
+            'device_id': '$termination_b_device'
+        }
+    )
+
+
+class ConnectCableToCircuitTerminationForm(BootstrapMixin, CustomFieldModelForm):
+    termination_b_provider = DynamicModelChoiceField(
+        queryset=Provider.objects.all(),
+        label='Provider',
+        required=False
+    )
+    termination_b_region = DynamicModelChoiceField(
+        queryset=Region.objects.all(),
+        label='Region',
+        required=False
+    )
+    termination_b_site_group = DynamicModelChoiceField(
+        queryset=SiteGroup.objects.all(),
+        label='Site group',
+        required=False
+    )
+    termination_b_site = DynamicModelChoiceField(
+        queryset=Site.objects.all(),
+        label='Site',
+        required=False,
+        query_params={
+            'region_id': '$termination_b_region',
+            'group_id': '$termination_b_site_group',
+        }
+    )
+    termination_b_circuit = DynamicModelChoiceField(
+        queryset=Circuit.objects.all(),
+        label='Circuit',
+        query_params={
+            'provider_id': '$termination_b_provider',
+            'site_id': '$termination_b_site',
+        }
+    )
+    termination_b_id = DynamicModelChoiceField(
+        queryset=CircuitTermination.objects.all(),
+        label='Side',
+        disabled_indicator='_occupied',
+        query_params={
+            'circuit_id': '$termination_b_circuit'
+        }
+    )
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
+        required=False
+    )
+
+    class Meta:
+        model = Cable
+        fields = [
+            'termination_b_provider', 'termination_b_region', 'termination_b_site', 'termination_b_circuit',
+            'termination_b_id', 'type', 'status', 'label', 'color', 'length', 'length_unit', 'tags',
+        ]
+
+    def clean_termination_b_id(self):
+        # Return the PK rather than the object
+        return getattr(self.cleaned_data['termination_b_id'], 'pk', None)
+
+
+class ConnectCableToPowerFeedForm(BootstrapMixin, CustomFieldModelForm):
+    termination_b_region = DynamicModelChoiceField(
+        queryset=Region.objects.all(),
+        label='Region',
+        required=False
+    )
+    termination_b_site_group = DynamicModelChoiceField(
+        queryset=SiteGroup.objects.all(),
+        label='Site group',
+        required=False
+    )
+    termination_b_site = DynamicModelChoiceField(
+        queryset=Site.objects.all(),
+        label='Site',
+        required=False,
+        query_params={
+            'region_id': '$termination_b_region',
+            'group_id': '$termination_b_site_group',
+        }
+    )
+    termination_b_location = DynamicModelChoiceField(
+        queryset=Location.objects.all(),
+        label='Location',
+        required=False,
+        query_params={
+            'site_id': '$termination_b_site'
+        }
+    )
+    termination_b_powerpanel = DynamicModelChoiceField(
+        queryset=PowerPanel.objects.all(),
+        label='Power Panel',
+        required=False,
+        query_params={
+            'site_id': '$termination_b_site',
+            'location_id': '$termination_b_location',
+        }
+    )
+    termination_b_id = DynamicModelChoiceField(
+        queryset=PowerFeed.objects.all(),
+        label='Name',
+        disabled_indicator='_occupied',
+        query_params={
+            'power_panel_id': '$termination_b_powerpanel'
+        }
+    )
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
+        required=False
+    )
+
+    class Meta:
+        model = Cable
+        fields = [
+            'termination_b_location', 'termination_b_powerpanel', 'termination_b_id', 'type', 'status', 'label',
+            'color', 'length', 'length_unit', 'tags',
+        ]
+
+    def clean_termination_b_id(self):
+        # Return the PK rather than the object
+        return getattr(self.cleaned_data['termination_b_id'], 'pk', None)

+ 25 - 0
netbox/dcim/forms/fields.py

@@ -0,0 +1,25 @@
+from django import forms
+from netaddr import EUI
+from netaddr.core import AddrFormatError
+
+__all__ = (
+    'MACAddressField',
+)
+
+
+class MACAddressField(forms.Field):
+    widget = forms.CharField
+    default_error_messages = {
+        'invalid': 'MAC address must be in EUI-48 format',
+    }
+
+    def to_python(self, value):
+        value = super().to_python(value)
+
+        # Validate MAC address format
+        try:
+            value = EUI(value.strip())
+        except AddrFormatError:
+            raise forms.ValidationError(self.error_messages['invalid'], code='invalid')
+
+        return value

+ 1143 - 0
netbox/dcim/forms/filtersets.py

@@ -0,0 +1,1143 @@
+from django import forms
+from django.contrib.auth.models import User
+from django.utils.translation import gettext as _
+
+from dcim.choices import *
+from dcim.constants import *
+from dcim.models import *
+from extras.forms import CustomFieldModelFilterForm, LocalConfigContextFilterForm
+from tenancy.forms import TenancyFilterForm
+from tenancy.models import Tenant
+from utilities.forms import (
+    APISelectMultiple, add_blank_choice, BootstrapMixin, ColorField, DynamicModelMultipleChoiceField, StaticSelect,
+    StaticSelectMultiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES,
+)
+
+__all__ = (
+    'CableFilterForm',
+    'ConsoleConnectionFilterForm',
+    'ConsolePortFilterForm',
+    'ConsoleServerPortFilterForm',
+    'DeviceBayFilterForm',
+    'DeviceFilterForm',
+    'DeviceRoleFilterForm',
+    'DeviceTypeFilterForm',
+    'FrontPortFilterForm',
+    'InterfaceConnectionFilterForm',
+    'InterfaceFilterForm',
+    'InventoryItemFilterForm',
+    'LocationFilterForm',
+    'ManufacturerFilterForm',
+    'PlatformFilterForm',
+    'PowerConnectionFilterForm',
+    'PowerFeedFilterForm',
+    'PowerOutletFilterForm',
+    'PowerPanelFilterForm',
+    'PowerPortFilterForm',
+    'RackFilterForm',
+    'RackElevationFilterForm',
+    'RackReservationFilterForm',
+    'RackRoleFilterForm',
+    'RearPortFilterForm',
+    'RegionFilterForm',
+    'SiteFilterForm',
+    'SiteGroupFilterForm',
+    'VirtualChassisFilterForm',
+)
+
+
+class DeviceComponentFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
+    field_order = [
+        'q', 'name', 'label', 'region_id', 'site_group_id', 'site_id',
+    ]
+    q = forms.CharField(
+        required=False,
+        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
+        label=_('Search')
+    )
+    name = forms.CharField(
+        required=False
+    )
+    label = forms.CharField(
+        required=False
+    )
+    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,
+        query_params={
+            'region_id': '$region_id',
+            'group_id': '$site_group_id',
+        },
+        label=_('Site'),
+        fetch_trigger='open'
+    )
+    location_id = DynamicModelMultipleChoiceField(
+        queryset=Location.objects.all(),
+        required=False,
+        query_params={
+            'site_id': '$site_id',
+        },
+        label=_('Location'),
+        fetch_trigger='open'
+    )
+    device_id = DynamicModelMultipleChoiceField(
+        queryset=Device.objects.all(),
+        required=False,
+        query_params={
+            'site_id': '$site_id',
+            'location_id': '$location_id',
+        },
+        label=_('Device'),
+        fetch_trigger='open'
+    )
+
+
+class RegionFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
+    model = Region
+    field_groups = [
+        ['q'],
+        ['parent_id'],
+    ]
+    q = forms.CharField(
+        required=False,
+        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
+        label=_('Search')
+    )
+    parent_id = DynamicModelMultipleChoiceField(
+        queryset=Region.objects.all(),
+        required=False,
+        label=_('Parent region'),
+        fetch_trigger='open'
+    )
+
+
+class SiteGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
+    model = SiteGroup
+    field_groups = [
+        ['q'],
+        ['parent_id'],
+    ]
+    q = forms.CharField(
+        required=False,
+        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
+        label=_('Search')
+    )
+    parent_id = DynamicModelMultipleChoiceField(
+        queryset=SiteGroup.objects.all(),
+        required=False,
+        label=_('Parent group'),
+        fetch_trigger='open'
+    )
+
+
+class SiteFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
+    model = Site
+    field_order = ['q', 'status', 'region_id', 'tenant_group_id', 'tenant_id']
+    field_groups = [
+        ['q', 'tag'],
+        ['status', 'region_id', 'group_id'],
+        ['tenant_group_id', 'tenant_id'],
+    ]
+    q = forms.CharField(
+        required=False,
+        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
+        label=_('Search')
+    )
+    status = forms.MultipleChoiceField(
+        choices=SiteStatusChoices,
+        required=False,
+        widget=StaticSelectMultiple(),
+    )
+    region_id = DynamicModelMultipleChoiceField(
+        queryset=Region.objects.all(),
+        required=False,
+        label=_('Region'),
+        fetch_trigger='open'
+    )
+    group_id = DynamicModelMultipleChoiceField(
+        queryset=SiteGroup.objects.all(),
+        required=False,
+        label=_('Site group'),
+        fetch_trigger='open'
+    )
+    tag = TagFilterField(model)
+
+
+class LocationFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
+    model = Location
+    q = forms.CharField(
+        required=False,
+        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
+        label=_('Search')
+    )
+    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,
+        query_params={
+            'region_id': '$region_id',
+            'group_id': '$site_group_id',
+        },
+        label=_('Site'),
+        fetch_trigger='open'
+    )
+    parent_id = DynamicModelMultipleChoiceField(
+        queryset=Location.objects.all(),
+        required=False,
+        query_params={
+            'region_id': '$region_id',
+            'site_id': '$site_id',
+        },
+        label=_('Parent'),
+        fetch_trigger='open'
+    )
+
+
+class RackRoleFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
+    model = RackRole
+    field_groups = [
+        ['q'],
+    ]
+    q = forms.CharField(
+        required=False,
+        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
+        label=_('Search')
+    )
+
+
+class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
+    model = Rack
+    field_order = ['q', 'region_id', 'site_id', 'location_id', 'status', 'role_id', 'tenant_group_id', 'tenant_id']
+    field_groups = [
+        ['q', 'tag'],
+        ['region_id', 'site_id', 'location_id'],
+        ['status', 'role_id'],
+        ['type', 'width', 'serial', 'asset_tag'],
+        ['tenant_group_id', 'tenant_id'],
+    ]
+    q = forms.CharField(
+        required=False,
+        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
+        label=_('Search')
+    )
+    region_id = DynamicModelMultipleChoiceField(
+        queryset=Region.objects.all(),
+        required=False,
+        label=_('Region'),
+        fetch_trigger='open'
+    )
+    site_id = DynamicModelMultipleChoiceField(
+        queryset=Site.objects.all(),
+        required=False,
+        query_params={
+            'region_id': '$region_id'
+        },
+        label=_('Site'),
+        fetch_trigger='open'
+    )
+    location_id = DynamicModelMultipleChoiceField(
+        queryset=Location.objects.all(),
+        required=False,
+        null_option='None',
+        query_params={
+            'site_id': '$site_id'
+        },
+        label=_('Location'),
+        fetch_trigger='open'
+    )
+    status = forms.MultipleChoiceField(
+        choices=RackStatusChoices,
+        required=False,
+        widget=StaticSelectMultiple()
+    )
+    type = forms.MultipleChoiceField(
+        choices=RackTypeChoices,
+        required=False,
+        widget=StaticSelectMultiple()
+    )
+    width = forms.MultipleChoiceField(
+        choices=RackWidthChoices,
+        required=False,
+        widget=StaticSelectMultiple()
+    )
+    role_id = DynamicModelMultipleChoiceField(
+        queryset=RackRole.objects.all(),
+        required=False,
+        null_option='None',
+        label=_('Role'),
+        fetch_trigger='open'
+    )
+    serial = forms.CharField(
+        required=False
+    )
+    asset_tag = forms.CharField(
+        required=False
+    )
+    tag = TagFilterField(model)
+
+
+class RackElevationFilterForm(RackFilterForm):
+    field_order = [
+        'q', 'region_id', 'site_id', 'location_id', 'id', 'status', 'role_id', 'tenant_group_id',
+        'tenant_id',
+    ]
+    id = DynamicModelMultipleChoiceField(
+        queryset=Rack.objects.all(),
+        label=_('Rack'),
+        required=False,
+        query_params={
+            'site_id': '$site_id',
+            'location_id': '$location_id',
+        },
+        fetch_trigger='open'
+    )
+
+
+class RackReservationFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
+    model = RackReservation
+    field_order = ['q', 'region_id', 'site_id', 'location_id', 'user_id', 'tenant_group_id', 'tenant_id']
+    field_groups = [
+        ['q', 'tag'],
+        ['user_id'],
+        ['region_id', 'site_id', 'location_id'],
+        ['tenant_group_id', 'tenant_id'],
+    ]
+    q = forms.CharField(
+        required=False,
+        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
+        label=_('Search')
+    )
+    region_id = DynamicModelMultipleChoiceField(
+        queryset=Region.objects.all(),
+        required=False,
+        label=_('Region'),
+        fetch_trigger='open'
+    )
+    site_id = DynamicModelMultipleChoiceField(
+        queryset=Site.objects.all(),
+        required=False,
+        query_params={
+            'region_id': '$region_id'
+        },
+        label=_('Site'),
+        fetch_trigger='open'
+    )
+    location_id = DynamicModelMultipleChoiceField(
+        queryset=Location.objects.prefetch_related('site'),
+        required=False,
+        label=_('Location'),
+        null_option='None',
+        fetch_trigger='open'
+    )
+    user_id = DynamicModelMultipleChoiceField(
+        queryset=User.objects.all(),
+        required=False,
+        label=_('User'),
+        widget=APISelectMultiple(
+            api_url='/api/users/users/',
+        ),
+        fetch_trigger='open'
+    )
+    tag = TagFilterField(model)
+
+
+class ManufacturerFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
+    model = Manufacturer
+    field_groups = [
+        ['q'],
+    ]
+    q = forms.CharField(
+        required=False,
+        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
+        label=_('Search')
+    )
+
+
+class DeviceTypeFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
+    model = DeviceType
+    field_groups = [
+        ['q', 'tag'],
+        ['manufacturer_id', 'subdevice_role'],
+        ['console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports'],
+    ]
+    q = forms.CharField(
+        required=False,
+        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
+        label=_('Search')
+    )
+    manufacturer_id = DynamicModelMultipleChoiceField(
+        queryset=Manufacturer.objects.all(),
+        required=False,
+        label=_('Manufacturer'),
+        fetch_trigger='open'
+    )
+    subdevice_role = forms.MultipleChoiceField(
+        choices=add_blank_choice(SubdeviceRoleChoices),
+        required=False,
+        widget=StaticSelectMultiple()
+    )
+    console_ports = forms.NullBooleanField(
+        required=False,
+        label='Has console ports',
+        widget=StaticSelect(
+            choices=BOOLEAN_WITH_BLANK_CHOICES
+        )
+    )
+    console_server_ports = forms.NullBooleanField(
+        required=False,
+        label='Has console server ports',
+        widget=StaticSelect(
+            choices=BOOLEAN_WITH_BLANK_CHOICES
+        )
+    )
+    power_ports = forms.NullBooleanField(
+        required=False,
+        label='Has power ports',
+        widget=StaticSelect(
+            choices=BOOLEAN_WITH_BLANK_CHOICES
+        )
+    )
+    power_outlets = forms.NullBooleanField(
+        required=False,
+        label='Has power outlets',
+        widget=StaticSelect(
+            choices=BOOLEAN_WITH_BLANK_CHOICES
+        )
+    )
+    interfaces = forms.NullBooleanField(
+        required=False,
+        label='Has interfaces',
+        widget=StaticSelect(
+            choices=BOOLEAN_WITH_BLANK_CHOICES
+        )
+    )
+    pass_through_ports = forms.NullBooleanField(
+        required=False,
+        label='Has pass-through ports',
+        widget=StaticSelect(
+            choices=BOOLEAN_WITH_BLANK_CHOICES
+        )
+    )
+    tag = TagFilterField(model)
+
+
+class DeviceRoleFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
+    model = DeviceRole
+    field_groups = [
+        ['q'],
+    ]
+    q = forms.CharField(
+        required=False,
+        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
+        label=_('Search')
+    )
+
+
+class PlatformFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
+    model = Platform
+    q = forms.CharField(
+        required=False,
+        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
+        label=_('Search')
+    )
+    manufacturer_id = DynamicModelMultipleChoiceField(
+        queryset=Manufacturer.objects.all(),
+        required=False,
+        label=_('Manufacturer'),
+        fetch_trigger='open'
+    )
+
+
+class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilterForm, CustomFieldModelFilterForm):
+    model = Device
+    field_order = [
+        'q', 'region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'status', 'role_id', 'tenant_group_id',
+        'tenant_id', 'manufacturer_id', 'device_type_id', 'asset_tag', 'mac_address', 'has_primary_ip',
+    ]
+    field_groups = [
+        ['q', 'tag'],
+        ['region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id'],
+        ['status', 'role_id', 'serial', 'asset_tag', 'mac_address'],
+        ['manufacturer_id', 'device_type_id', 'platform_id'],
+        ['tenant_group_id', 'tenant_id'],
+        [
+            'has_primary_ip', 'virtual_chassis_member', 'console_ports', 'console_server_ports', 'power_ports',
+            'power_outlets', 'interfaces', 'pass_through_ports', 'local_context_data',
+        ],
+    ]
+    q = forms.CharField(
+        required=False,
+        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
+        label=_('Search')
+    )
+    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,
+        query_params={
+            'region_id': '$region_id',
+            'group_id': '$site_group_id',
+        },
+        label=_('Site'),
+        fetch_trigger='open'
+    )
+    location_id = DynamicModelMultipleChoiceField(
+        queryset=Location.objects.all(),
+        required=False,
+        null_option='None',
+        query_params={
+            'site_id': '$site_id'
+        },
+        label=_('Location'),
+        fetch_trigger='open'
+    )
+    rack_id = DynamicModelMultipleChoiceField(
+        queryset=Rack.objects.all(),
+        required=False,
+        null_option='None',
+        query_params={
+            'site_id': '$site_id',
+            'location_id': '$location_id',
+        },
+        label=_('Rack'),
+        fetch_trigger='open'
+    )
+    role_id = DynamicModelMultipleChoiceField(
+        queryset=DeviceRole.objects.all(),
+        required=False,
+        label=_('Role'),
+        fetch_trigger='open'
+    )
+    manufacturer_id = DynamicModelMultipleChoiceField(
+        queryset=Manufacturer.objects.all(),
+        required=False,
+        label=_('Manufacturer'),
+        fetch_trigger='open'
+    )
+    device_type_id = DynamicModelMultipleChoiceField(
+        queryset=DeviceType.objects.all(),
+        required=False,
+        query_params={
+            'manufacturer_id': '$manufacturer_id'
+        },
+        label=_('Model'),
+        fetch_trigger='open'
+    )
+    platform_id = DynamicModelMultipleChoiceField(
+        queryset=Platform.objects.all(),
+        required=False,
+        null_option='None',
+        label=_('Platform'),
+        fetch_trigger='open'
+    )
+    status = forms.MultipleChoiceField(
+        choices=DeviceStatusChoices,
+        required=False,
+        widget=StaticSelectMultiple()
+    )
+    serial = forms.CharField(
+        required=False
+    )
+    asset_tag = forms.CharField(
+        required=False
+    )
+    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
+        )
+    )
+    virtual_chassis_member = forms.NullBooleanField(
+        required=False,
+        label='Virtual chassis member',
+        widget=StaticSelect(
+            choices=BOOLEAN_WITH_BLANK_CHOICES
+        )
+    )
+    console_ports = forms.NullBooleanField(
+        required=False,
+        label='Has console ports',
+        widget=StaticSelect(
+            choices=BOOLEAN_WITH_BLANK_CHOICES
+        )
+    )
+    console_server_ports = forms.NullBooleanField(
+        required=False,
+        label='Has console server ports',
+        widget=StaticSelect(
+            choices=BOOLEAN_WITH_BLANK_CHOICES
+        )
+    )
+    power_ports = forms.NullBooleanField(
+        required=False,
+        label='Has power ports',
+        widget=StaticSelect(
+            choices=BOOLEAN_WITH_BLANK_CHOICES
+        )
+    )
+    power_outlets = forms.NullBooleanField(
+        required=False,
+        label='Has power outlets',
+        widget=StaticSelect(
+            choices=BOOLEAN_WITH_BLANK_CHOICES
+        )
+    )
+    interfaces = forms.NullBooleanField(
+        required=False,
+        label='Has interfaces',
+        widget=StaticSelect(
+            choices=BOOLEAN_WITH_BLANK_CHOICES
+        )
+    )
+    pass_through_ports = forms.NullBooleanField(
+        required=False,
+        label='Has pass-through ports',
+        widget=StaticSelect(
+            choices=BOOLEAN_WITH_BLANK_CHOICES
+        )
+    )
+    tag = TagFilterField(model)
+
+
+class VirtualChassisFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
+    model = VirtualChassis
+    field_order = ['q', 'region_id', 'site_group_id', 'site_id', 'tenant_group_id', 'tenant_id']
+    field_groups = [
+        ['q', 'tag'],
+        ['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')
+    )
+    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,
+        query_params={
+            'region_id': '$region_id',
+            'group_id': '$site_group_id',
+        },
+        label=_('Site'),
+        fetch_trigger='open'
+    )
+    tag = TagFilterField(model)
+
+
+class CableFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
+    model = Cable
+    field_groups = [
+        ['q', 'tag'],
+        ['site_id', 'rack_id', 'device_id'],
+        ['type', 'status', 'color'],
+        ['tenant_id'],
+    ]
+    q = forms.CharField(
+        required=False,
+        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
+        label=_('Search')
+    )
+    region_id = DynamicModelMultipleChoiceField(
+        queryset=Region.objects.all(),
+        required=False,
+        label=_('Region'),
+        fetch_trigger='open'
+    )
+    site_id = DynamicModelMultipleChoiceField(
+        queryset=Site.objects.all(),
+        required=False,
+        query_params={
+            'region_id': '$region_id'
+        },
+        label=_('Site'),
+        fetch_trigger='open'
+    )
+    tenant_id = DynamicModelMultipleChoiceField(
+        queryset=Tenant.objects.all(),
+        required=False,
+        label=_('Tenant'),
+        fetch_trigger='open'
+    )
+    rack_id = DynamicModelMultipleChoiceField(
+        queryset=Rack.objects.all(),
+        required=False,
+        label=_('Rack'),
+        null_option='None',
+        query_params={
+            'site_id': '$site_id'
+        },
+        fetch_trigger='open'
+    )
+    type = forms.MultipleChoiceField(
+        choices=add_blank_choice(CableTypeChoices),
+        required=False,
+        widget=StaticSelect()
+    )
+    status = forms.ChoiceField(
+        required=False,
+        choices=add_blank_choice(CableStatusChoices),
+        widget=StaticSelect()
+    )
+    color = ColorField(
+        required=False
+    )
+    device_id = DynamicModelMultipleChoiceField(
+        queryset=Device.objects.all(),
+        required=False,
+        query_params={
+            'site_id': '$site_id',
+            'tenant_id': '$tenant_id',
+            'rack_id': '$rack_id',
+        },
+        label=_('Device'),
+        fetch_trigger='open'
+    )
+    tag = TagFilterField(model)
+
+
+class PowerPanelFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
+    model = PowerPanel
+    field_groups = (
+        ('q', 'tag'),
+        ('region_id', 'site_group_id', 'site_id', 'location_id')
+    )
+    q = forms.CharField(
+        required=False,
+        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
+        label=_('Search')
+    )
+    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,
+        query_params={
+            'region_id': '$region_id',
+            'group_id': '$site_group_id',
+        },
+        label=_('Site'),
+        fetch_trigger='open'
+    )
+    location_id = DynamicModelMultipleChoiceField(
+        queryset=Location.objects.all(),
+        required=False,
+        null_option='None',
+        query_params={
+            'site_id': '$site_id'
+        },
+        label=_('Location'),
+        fetch_trigger='open'
+    )
+    tag = TagFilterField(model)
+
+
+class PowerFeedFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
+    model = PowerFeed
+    field_groups = [
+        ['q', 'tag'],
+        ['region_id', 'site_group_id', 'site_id'],
+        ['power_panel_id', 'rack_id'],
+        ['status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization'],
+    ]
+    q = forms.CharField(
+        required=False,
+        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
+        label=_('Search')
+    )
+    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,
+        query_params={
+            'region_id': '$region_id'
+        },
+        label=_('Site'),
+        fetch_trigger='open'
+    )
+    power_panel_id = DynamicModelMultipleChoiceField(
+        queryset=PowerPanel.objects.all(),
+        required=False,
+        null_option='None',
+        query_params={
+            'site_id': '$site_id'
+        },
+        label=_('Power panel'),
+        fetch_trigger='open'
+    )
+    rack_id = DynamicModelMultipleChoiceField(
+        queryset=Rack.objects.all(),
+        required=False,
+        null_option='None',
+        query_params={
+            'site_id': '$site_id'
+        },
+        label=_('Rack'),
+        fetch_trigger='open'
+    )
+    status = forms.MultipleChoiceField(
+        choices=PowerFeedStatusChoices,
+        required=False,
+        widget=StaticSelectMultiple()
+    )
+    type = forms.ChoiceField(
+        choices=add_blank_choice(PowerFeedTypeChoices),
+        required=False,
+        widget=StaticSelect()
+    )
+    supply = forms.ChoiceField(
+        choices=add_blank_choice(PowerFeedSupplyChoices),
+        required=False,
+        widget=StaticSelect()
+    )
+    phase = forms.ChoiceField(
+        choices=add_blank_choice(PowerFeedPhaseChoices),
+        required=False,
+        widget=StaticSelect()
+    )
+    voltage = forms.IntegerField(
+        required=False
+    )
+    amperage = forms.IntegerField(
+        required=False
+    )
+    max_utilization = forms.IntegerField(
+        required=False
+    )
+    tag = TagFilterField(model)
+
+
+#
+# Device components
+#
+
+class ConsolePortFilterForm(DeviceComponentFilterForm):
+    model = ConsolePort
+    field_groups = [
+        ['q', 'tag'],
+        ['name', 'label', 'type', 'speed'],
+        ['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
+    ]
+    type = forms.MultipleChoiceField(
+        choices=ConsolePortTypeChoices,
+        required=False,
+        widget=StaticSelectMultiple()
+    )
+    speed = forms.MultipleChoiceField(
+        choices=ConsolePortSpeedChoices,
+        required=False,
+        widget=StaticSelectMultiple()
+    )
+    tag = TagFilterField(model)
+
+
+class ConsoleServerPortFilterForm(DeviceComponentFilterForm):
+    model = ConsoleServerPort
+    field_groups = [
+        ['q', 'tag'],
+        ['name', 'label', 'type', 'speed'],
+        ['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
+    ]
+    type = forms.MultipleChoiceField(
+        choices=ConsolePortTypeChoices,
+        required=False,
+        widget=StaticSelectMultiple()
+    )
+    speed = forms.MultipleChoiceField(
+        choices=ConsolePortSpeedChoices,
+        required=False,
+        widget=StaticSelectMultiple()
+    )
+    tag = TagFilterField(model)
+
+
+class PowerPortFilterForm(DeviceComponentFilterForm):
+    model = PowerPort
+    field_groups = [
+        ['q', 'tag'],
+        ['name', 'label', 'type'],
+        ['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
+    ]
+    type = forms.MultipleChoiceField(
+        choices=PowerPortTypeChoices,
+        required=False,
+        widget=StaticSelectMultiple()
+    )
+    tag = TagFilterField(model)
+
+
+class PowerOutletFilterForm(DeviceComponentFilterForm):
+    model = PowerOutlet
+    field_groups = [
+        ['q', 'tag'],
+        ['name', 'label', 'type'],
+        ['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
+    ]
+    type = forms.MultipleChoiceField(
+        choices=PowerOutletTypeChoices,
+        required=False,
+        widget=StaticSelectMultiple()
+    )
+    tag = TagFilterField(model)
+
+
+class InterfaceFilterForm(DeviceComponentFilterForm):
+    model = Interface
+    field_groups = [
+        ['q', 'tag'],
+        ['name', 'label', 'type', 'enabled', 'mgmt_only', 'mac_address'],
+        ['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
+    ]
+    type = forms.MultipleChoiceField(
+        choices=InterfaceTypeChoices,
+        required=False,
+        widget=StaticSelectMultiple()
+    )
+    enabled = forms.NullBooleanField(
+        required=False,
+        widget=StaticSelect(
+            choices=BOOLEAN_WITH_BLANK_CHOICES
+        )
+    )
+    mgmt_only = forms.NullBooleanField(
+        required=False,
+        widget=StaticSelect(
+            choices=BOOLEAN_WITH_BLANK_CHOICES
+        )
+    )
+    mac_address = forms.CharField(
+        required=False,
+        label='MAC address'
+    )
+    tag = TagFilterField(model)
+
+
+class FrontPortFilterForm(DeviceComponentFilterForm):
+    field_groups = [
+        ['q', 'tag'],
+        ['name', 'label', 'type', 'color'],
+        ['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
+    ]
+    model = FrontPort
+    type = forms.MultipleChoiceField(
+        choices=PortTypeChoices,
+        required=False,
+        widget=StaticSelectMultiple()
+    )
+    color = ColorField(
+        required=False
+    )
+    tag = TagFilterField(model)
+
+
+class RearPortFilterForm(DeviceComponentFilterForm):
+    model = RearPort
+    field_groups = [
+        ['q', 'tag'],
+        ['name', 'label', 'type', 'color'],
+        ['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
+    ]
+    type = forms.MultipleChoiceField(
+        choices=PortTypeChoices,
+        required=False,
+        widget=StaticSelectMultiple()
+    )
+    color = ColorField(
+        required=False
+    )
+    tag = TagFilterField(model)
+
+
+class DeviceBayFilterForm(DeviceComponentFilterForm):
+    model = DeviceBay
+    field_groups = [
+        ['q', 'tag'],
+        ['name', 'label'],
+        ['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
+    ]
+    tag = TagFilterField(model)
+
+
+class InventoryItemFilterForm(DeviceComponentFilterForm):
+    model = InventoryItem
+    field_groups = [
+        ['q', 'tag'],
+        ['name', 'label', 'manufacturer_id', 'serial', 'asset_tag', 'discovered'],
+        ['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
+    ]
+    manufacturer_id = DynamicModelMultipleChoiceField(
+        queryset=Manufacturer.objects.all(),
+        required=False,
+        label=_('Manufacturer'),
+        fetch_trigger='open'
+    )
+    serial = forms.CharField(
+        required=False
+    )
+    asset_tag = forms.CharField(
+        required=False
+    )
+    discovered = forms.NullBooleanField(
+        required=False,
+        widget=StaticSelect(
+            choices=BOOLEAN_WITH_BLANK_CHOICES
+        )
+    )
+    tag = TagFilterField(model)
+
+
+#
+# Connections
+#
+
+class ConsoleConnectionFilterForm(BootstrapMixin, forms.Form):
+    region_id = DynamicModelMultipleChoiceField(
+        queryset=Region.objects.all(),
+        required=False,
+        label=_('Region'),
+        fetch_trigger='open'
+    )
+    site_id = DynamicModelMultipleChoiceField(
+        queryset=Site.objects.all(),
+        required=False,
+        query_params={
+            'region_id': '$region_id'
+        },
+        label=_('Site'),
+        fetch_trigger='open'
+    )
+    device_id = DynamicModelMultipleChoiceField(
+        queryset=Device.objects.all(),
+        required=False,
+        query_params={
+            'site_id': '$site_id'
+        },
+        label=_('Device'),
+        fetch_trigger='open'
+    )
+
+
+class PowerConnectionFilterForm(BootstrapMixin, forms.Form):
+    region_id = DynamicModelMultipleChoiceField(
+        queryset=Region.objects.all(),
+        required=False,
+        label=_('Region'),
+        fetch_trigger='open'
+    )
+    site_id = DynamicModelMultipleChoiceField(
+        queryset=Site.objects.all(),
+        required=False,
+        query_params={
+            'region_id': '$region_id'
+        },
+        label=_('Site'),
+        fetch_trigger='open'
+    )
+    device_id = DynamicModelMultipleChoiceField(
+        queryset=Device.objects.all(),
+        required=False,
+        query_params={
+            'site_id': '$site_id'
+        },
+        label=_('Device'),
+        fetch_trigger='open'
+    )
+
+
+class InterfaceConnectionFilterForm(BootstrapMixin, forms.Form):
+    region_id = DynamicModelMultipleChoiceField(
+        queryset=Region.objects.all(),
+        required=False,
+        label=_('Region'),
+        fetch_trigger='open'
+    )
+    site_id = DynamicModelMultipleChoiceField(
+        queryset=Site.objects.all(),
+        required=False,
+        query_params={
+            'region_id': '$region_id'
+        },
+        label=_('Site'),
+        fetch_trigger='open'
+    )
+    device_id = DynamicModelMultipleChoiceField(
+        queryset=Device.objects.all(),
+        required=False,
+        query_params={
+            'site_id': '$site_id'
+        },
+        label=_('Device'),
+        fetch_trigger='open'
+    )

+ 21 - 0
netbox/dcim/forms/formsets.py

@@ -0,0 +1,21 @@
+from django import forms
+
+__all__ = (
+    'BaseVCMemberFormSet',
+)
+
+
+class BaseVCMemberFormSet(forms.BaseModelFormSet):
+
+    def clean(self):
+        super().clean()
+
+        # Check for duplicate VC position values
+        vc_position_list = []
+        for form in self.forms:
+            vc_position = form.cleaned_data.get('vc_position')
+            if vc_position:
+                if vc_position in vc_position_list:
+                    error_msg = f"A virtual chassis member already exists in position {vc_position}."
+                    form.add_error('vc_position', error_msg)
+                vc_position_list.append(vc_position)

+ 1232 - 0
netbox/dcim/forms/models.py

@@ -0,0 +1,1232 @@
+from django import forms
+from django.contrib.auth.models import User
+from django.contrib.contenttypes.models import ContentType
+from timezone_field import TimeZoneFormField
+
+from dcim.choices import *
+from dcim.constants import *
+from dcim.models import *
+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 (
+    APISelect, add_blank_choice, BootstrapMixin, ClearableFileInput, CommentField, DynamicModelChoiceField,
+    DynamicModelMultipleChoiceField, JSONField, NumericArrayField, SelectWithPK, SmallTextarea,
+    SlugField, StaticSelect,
+)
+from virtualization.models import Cluster, ClusterGroup
+from .common import InterfaceCommonForm
+
+__all__ = (
+    'CableForm',
+    'ConsolePortForm',
+    'ConsolePortTemplateForm',
+    'ConsoleServerPortForm',
+    'ConsoleServerPortTemplateForm',
+    'DeviceBayForm',
+    'DeviceBayTemplateForm',
+    'DeviceForm',
+    'DeviceRoleForm',
+    'DeviceTypeForm',
+    'DeviceVCMembershipForm',
+    'FrontPortForm',
+    'FrontPortTemplateForm',
+    'InterfaceForm',
+    'InterfaceTemplateForm',
+    'InventoryItemForm',
+    'LocationForm',
+    'ManufacturerForm',
+    'PlatformForm',
+    'PowerFeedForm',
+    'PowerOutletForm',
+    'PowerOutletTemplateForm',
+    'PowerPanelForm',
+    'PowerPortForm',
+    'PowerPortTemplateForm',
+    'RackForm',
+    'RackReservationForm',
+    'RackRoleForm',
+    'RearPortForm',
+    'RearPortTemplateForm',
+    'RegionForm',
+    'SiteForm',
+    'SiteGroupForm',
+    'VirtualChassisForm',
+)
+
+INTERFACE_MODE_HELP_TEXT = """
+Access: One untagged VLAN<br />
+Tagged: One untagged VLAN and/or one or more tagged VLANs<br />
+Tagged (All): Implies all VLANs are available (w/optional untagged VLAN)
+"""
+
+
+class RegionForm(BootstrapMixin, CustomFieldModelForm):
+    parent = DynamicModelChoiceField(
+        queryset=Region.objects.all(),
+        required=False
+    )
+    slug = SlugField()
+
+    class Meta:
+        model = Region
+        fields = (
+            'parent', 'name', 'slug', 'description',
+        )
+
+
+class SiteGroupForm(BootstrapMixin, CustomFieldModelForm):
+    parent = DynamicModelChoiceField(
+        queryset=SiteGroup.objects.all(),
+        required=False
+    )
+    slug = SlugField()
+
+    class Meta:
+        model = SiteGroup
+        fields = (
+            'parent', 'name', 'slug', 'description',
+        )
+
+
+class SiteForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
+    region = DynamicModelChoiceField(
+        queryset=Region.objects.all(),
+        required=False
+    )
+    group = DynamicModelChoiceField(
+        queryset=SiteGroup.objects.all(),
+        required=False
+    )
+    slug = SlugField()
+    time_zone = TimeZoneFormField(
+        choices=add_blank_choice(TimeZoneFormField().choices),
+        required=False,
+        widget=StaticSelect()
+    )
+    comments = CommentField()
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
+        required=False
+    )
+
+    class Meta:
+        model = Site
+        fields = [
+            'name', 'slug', 'status', 'region', 'group', 'tenant_group', 'tenant', 'facility', 'asn', 'time_zone',
+            'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name',
+            'contact_phone', 'contact_email', 'comments', 'tags',
+        ]
+        fieldsets = (
+            ('Site', (
+                'name', 'slug', 'status', 'region', 'group', 'facility', 'asn', 'time_zone', 'description', 'tags',
+            )),
+            ('Tenancy', ('tenant_group', 'tenant')),
+            ('Contact Info', (
+                'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone',
+                'contact_email',
+            )),
+        )
+        widgets = {
+            'physical_address': SmallTextarea(
+                attrs={
+                    'rows': 3,
+                }
+            ),
+            'shipping_address': SmallTextarea(
+                attrs={
+                    'rows': 3,
+                }
+            ),
+            'status': StaticSelect(),
+            'time_zone': StaticSelect(),
+        }
+        help_texts = {
+            'name': "Full name of the site",
+            'facility': "Data center provider and facility (e.g. Equinix NY7)",
+            'asn': "BGP autonomous system number",
+            'time_zone': "Local time zone",
+            'description': "Short description (will appear in sites list)",
+            'physical_address': "Physical location of the building (e.g. for GPS)",
+            'shipping_address': "If different from the physical address",
+            'latitude': "Latitude in decimal format (xx.yyyyyy)",
+            'longitude': "Longitude in decimal format (xx.yyyyyy)"
+        }
+
+
+class LocationForm(BootstrapMixin, CustomFieldModelForm):
+    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(),
+        query_params={
+            'region_id': '$region',
+            'group_id': '$site_group',
+        }
+    )
+    parent = DynamicModelChoiceField(
+        queryset=Location.objects.all(),
+        required=False,
+        query_params={
+            'site_id': '$site'
+        }
+    )
+    slug = SlugField()
+
+    class Meta:
+        model = Location
+        fields = (
+            'region', 'site_group', 'site', 'parent', 'name', 'slug', 'description',
+        )
+
+
+class RackRoleForm(BootstrapMixin, CustomFieldModelForm):
+    slug = SlugField()
+
+    class Meta:
+        model = RackRole
+        fields = [
+            'name', 'slug', 'color', 'description',
+        ]
+
+
+class RackForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
+    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(),
+        query_params={
+            'region_id': '$region',
+            'group_id': '$site_group',
+        }
+    )
+    location = DynamicModelChoiceField(
+        queryset=Location.objects.all(),
+        required=False,
+        query_params={
+            'site_id': '$site'
+        }
+    )
+    role = DynamicModelChoiceField(
+        queryset=RackRole.objects.all(),
+        required=False
+    )
+    comments = CommentField()
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
+        required=False
+    )
+
+    class Meta:
+        model = Rack
+        fields = [
+            'region', 'site_group', 'site', 'location', 'name', 'facility_id', 'tenant_group', 'tenant', 'status',
+            'role', 'serial', 'asset_tag', 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth',
+            'outer_unit', 'comments', 'tags',
+        ]
+        help_texts = {
+            'site': "The site at which the rack exists",
+            'name': "Organizational rack name",
+            'facility_id': "The unique rack ID assigned by the facility",
+            'u_height': "Height in rack units",
+        }
+        widgets = {
+            'status': StaticSelect(),
+            'type': StaticSelect(),
+            'width': StaticSelect(),
+            'outer_unit': StaticSelect(),
+        }
+
+
+class RackReservationForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
+    region = DynamicModelChoiceField(
+        queryset=Region.objects.all(),
+        required=False,
+        initial_params={
+            'sites': '$site'
+        },
+        fetch_trigger='open'
+    )
+    site_group = DynamicModelChoiceField(
+        queryset=SiteGroup.objects.all(),
+        required=False,
+        initial_params={
+            'sites': '$site'
+        },
+        fetch_trigger='open'
+    )
+    site = DynamicModelChoiceField(
+        queryset=Site.objects.all(),
+        required=False,
+        query_params={
+            'region_id': '$region',
+            'group_id': '$site_group',
+        },
+        fetch_trigger='open'
+    )
+    location = DynamicModelChoiceField(
+        queryset=Location.objects.all(),
+        required=False,
+        query_params={
+            'site_id': '$site'
+        },
+        fetch_trigger='open'
+    )
+    rack = DynamicModelChoiceField(
+        queryset=Rack.objects.all(),
+        query_params={
+            'site_id': '$site',
+            'location_id': '$location',
+        },
+        fetch_trigger='open'
+    )
+    units = NumericArrayField(
+        base_field=forms.IntegerField(),
+        help_text="Comma-separated list of numeric unit IDs. A range may be specified using a hyphen."
+    )
+    user = forms.ModelChoiceField(
+        queryset=User.objects.order_by(
+            'username'
+        ),
+        widget=StaticSelect()
+    )
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
+        required=False,
+        fetch_trigger='open'
+    )
+
+    class Meta:
+        model = RackReservation
+        fields = [
+            'region', 'site_group', 'site', 'location', 'rack', 'units', 'user', 'tenant_group', 'tenant',
+            'description', 'tags',
+        ]
+        fieldsets = (
+            ('Reservation', ('region', 'site', 'location', 'rack', 'units', 'user', 'description', 'tags')),
+            ('Tenancy', ('tenant_group', 'tenant')),
+        )
+
+
+class ManufacturerForm(BootstrapMixin, CustomFieldModelForm):
+    slug = SlugField()
+
+    class Meta:
+        model = Manufacturer
+        fields = [
+            'name', 'slug', 'description',
+        ]
+
+
+class DeviceTypeForm(BootstrapMixin, CustomFieldModelForm):
+    manufacturer = DynamicModelChoiceField(
+        queryset=Manufacturer.objects.all()
+    )
+    slug = SlugField(
+        slug_source='model'
+    )
+    comments = CommentField()
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
+        required=False
+    )
+
+    class Meta:
+        model = DeviceType
+        fields = [
+            'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role',
+            'front_image', 'rear_image', 'comments', 'tags',
+        ]
+        fieldsets = (
+            ('Device Type', (
+                'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'tags',
+            )),
+            ('Images', ('front_image', 'rear_image')),
+        )
+        widgets = {
+            'subdevice_role': StaticSelect(),
+            'front_image': ClearableFileInput(attrs={
+                'accept': DEVICETYPE_IMAGE_FORMATS
+            }),
+            'rear_image': ClearableFileInput(attrs={
+                'accept': DEVICETYPE_IMAGE_FORMATS
+            })
+        }
+
+
+class DeviceRoleForm(BootstrapMixin, CustomFieldModelForm):
+    slug = SlugField()
+
+    class Meta:
+        model = DeviceRole
+        fields = [
+            'name', 'slug', 'color', 'vm_role', 'description',
+        ]
+
+
+class PlatformForm(BootstrapMixin, CustomFieldModelForm):
+    manufacturer = DynamicModelChoiceField(
+        queryset=Manufacturer.objects.all(),
+        required=False
+    )
+    slug = SlugField(
+        max_length=64
+    )
+
+    class Meta:
+        model = Platform
+        fields = [
+            'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description',
+        ]
+        widgets = {
+            'napalm_args': SmallTextarea(),
+        }
+
+
+class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
+    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(),
+        query_params={
+            'region_id': '$region',
+            'group_id': '$site_group',
+        }
+    )
+    location = DynamicModelChoiceField(
+        queryset=Location.objects.all(),
+        required=False,
+        query_params={
+            'site_id': '$site'
+        },
+        initial_params={
+            'racks': '$rack'
+        }
+    )
+    rack = DynamicModelChoiceField(
+        queryset=Rack.objects.all(),
+        required=False,
+        query_params={
+            'site_id': '$site',
+            'location_id': '$location',
+        }
+    )
+    position = forms.IntegerField(
+        required=False,
+        help_text="The lowest-numbered unit occupied by the device",
+        widget=APISelect(
+            api_url='/api/dcim/racks/{{rack}}/elevation/',
+            attrs={
+                'disabled-indicator': 'device',
+                'data-dynamic-params': '[{"fieldName":"face","queryParam":"face"}]'
+            }
+        )
+    )
+    manufacturer = DynamicModelChoiceField(
+        queryset=Manufacturer.objects.all(),
+        required=False,
+        initial_params={
+            'device_types': '$device_type'
+        }
+    )
+    device_type = DynamicModelChoiceField(
+        queryset=DeviceType.objects.all(),
+        query_params={
+            'manufacturer_id': '$manufacturer'
+        }
+    )
+    device_role = DynamicModelChoiceField(
+        queryset=DeviceRole.objects.all()
+    )
+    platform = DynamicModelChoiceField(
+        queryset=Platform.objects.all(),
+        required=False,
+        query_params={
+            'manufacturer_id': ['$manufacturer', 'null']
+        }
+    )
+    cluster_group = DynamicModelChoiceField(
+        queryset=ClusterGroup.objects.all(),
+        required=False,
+        null_option='None',
+        initial_params={
+            'clusters': '$cluster'
+        }
+    )
+    cluster = DynamicModelChoiceField(
+        queryset=Cluster.objects.all(),
+        required=False,
+        query_params={
+            'group_id': '$cluster_group'
+        }
+    )
+    comments = CommentField()
+    local_context_data = JSONField(
+        required=False,
+        label=''
+    )
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
+        required=False
+    )
+
+    class Meta:
+        model = Device
+        fields = [
+            'name', 'device_role', 'device_type', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'rack',
+            'location', 'position', 'face', 'status', 'platform', 'primary_ip4', 'primary_ip6', 'cluster_group',
+            'cluster', 'tenant_group', 'tenant', 'comments', 'tags', 'local_context_data'
+        ]
+        help_texts = {
+            'device_role': "The function this device serves",
+            'serial': "Chassis serial number",
+            'local_context_data': "Local config context data overwrites all source contexts in the final rendered "
+                                  "config context",
+        }
+        widgets = {
+            'face': StaticSelect(),
+            '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 Device or a peer VirtualChassis member
+                interface_ids = self.instance.vc_interfaces(if_master=False).values_list('pk', flat=True)
+
+                # Collect interface IPs
+                interface_ips = IPAddress.objects.filter(
+                    address__family=family,
+                    assigned_object_type=ContentType.objects.get_for_model(Interface),
+                    assigned_object_id__in=interface_ids
+                ).prefetch_related('assigned_object')
+                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(Interface),
+                    nat_inside__assigned_object_id__in=interface_ids
+                ).prefetch_related('assigned_object')
+                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
+
+            # If editing an existing device, exclude it from the list of occupied rack units. This ensures that a device
+            # can be flipped from one face to another.
+            self.fields['position'].widget.add_query_param('exclude', self.instance.pk)
+
+            # Limit platform by manufacturer
+            self.fields['platform'].queryset = Platform.objects.filter(
+                Q(manufacturer__isnull=True) | Q(manufacturer=self.instance.device_type.manufacturer)
+            )
+
+            # Disable rack assignment if this is a child device installed in a parent device
+            if self.instance.device_type.is_child_device and hasattr(self.instance, 'parent_bay'):
+                self.fields['site'].disabled = True
+                self.fields['rack'].disabled = True
+                self.initial['site'] = self.instance.parent_bay.device.site_id
+                self.initial['rack'] = self.instance.parent_bay.device.rack_id
+
+        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
+
+        # Rack position
+        position = self.data.get('position') or self.initial.get('position')
+        if position:
+            self.fields['position'].widget.choices = [(position, f'U{position}')]
+
+
+class CableForm(BootstrapMixin, CustomFieldModelForm):
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
+        required=False
+    )
+
+    class Meta:
+        model = Cable
+        fields = [
+            'type', 'status', 'label', 'color', 'length', 'length_unit', 'tags',
+        ]
+        widgets = {
+            'status': StaticSelect,
+            'type': StaticSelect,
+            'length_unit': StaticSelect,
+        }
+        error_messages = {
+            'length': {
+                'max_value': 'Maximum length is 32767 (any unit)'
+            }
+        }
+
+
+class PowerPanelForm(BootstrapMixin, CustomFieldModelForm):
+    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(),
+        query_params={
+            'region_id': '$region',
+            'group_id': '$site_group',
+        }
+    )
+    location = DynamicModelChoiceField(
+        queryset=Location.objects.all(),
+        required=False,
+        query_params={
+            'site_id': '$site'
+        }
+    )
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
+        required=False
+    )
+
+    class Meta:
+        model = PowerPanel
+        fields = [
+            'region', 'site_group', 'site', 'location', 'name', 'tags',
+        ]
+        fieldsets = (
+            ('Power Panel', ('region', 'site_group', 'site', 'location', 'name', 'tags')),
+        )
+
+
+class PowerFeedForm(BootstrapMixin, CustomFieldModelForm):
+    region = DynamicModelChoiceField(
+        queryset=Region.objects.all(),
+        required=False,
+        initial_params={
+            'sites__powerpanel': '$power_panel'
+        }
+    )
+    site_group = DynamicModelChoiceField(
+        queryset=SiteGroup.objects.all(),
+        required=False,
+        initial_params={
+            'sites': '$site'
+        }
+    )
+    site = DynamicModelChoiceField(
+        queryset=Site.objects.all(),
+        required=False,
+        initial_params={
+            'powerpanel': '$power_panel'
+        },
+        query_params={
+            'region_id': '$region',
+            'group_id': '$site_group',
+        }
+    )
+    power_panel = DynamicModelChoiceField(
+        queryset=PowerPanel.objects.all(),
+        query_params={
+            'site_id': '$site'
+        }
+    )
+    rack = DynamicModelChoiceField(
+        queryset=Rack.objects.all(),
+        required=False,
+        query_params={
+            'site_id': '$site'
+        }
+    )
+    comments = CommentField()
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
+        required=False
+    )
+
+    class Meta:
+        model = PowerFeed
+        fields = [
+            'region', 'site_group', 'site', 'power_panel', 'rack', 'name', 'status', 'type', 'mark_connected', 'supply',
+            'phase', 'voltage', 'amperage', 'max_utilization', 'comments', 'tags',
+        ]
+        fieldsets = (
+            ('Power Panel', ('region', 'site', 'power_panel')),
+            ('Power Feed', ('rack', 'name', 'status', 'type', 'mark_connected', 'tags')),
+            ('Characteristics', ('supply', 'voltage', 'amperage', 'phase', 'max_utilization')),
+        )
+        widgets = {
+            'status': StaticSelect(),
+            'type': StaticSelect(),
+            'supply': StaticSelect(),
+            'phase': StaticSelect(),
+        }
+
+
+#
+# Virtual chassis
+#
+
+class VirtualChassisForm(BootstrapMixin, CustomFieldModelForm):
+    master = forms.ModelChoiceField(
+        queryset=Device.objects.all(),
+        required=False,
+    )
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
+        required=False
+    )
+
+    class Meta:
+        model = VirtualChassis
+        fields = [
+            'name', 'domain', 'master', 'tags',
+        ]
+        widgets = {
+            'master': SelectWithPK(),
+        }
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        self.fields['master'].queryset = Device.objects.filter(virtual_chassis=self.instance)
+
+
+class DeviceVCMembershipForm(forms.ModelForm):
+
+    class Meta:
+        model = Device
+        fields = [
+            'vc_position', 'vc_priority',
+        ]
+        labels = {
+            'vc_position': 'Position',
+            'vc_priority': 'Priority',
+        }
+
+    def __init__(self, validate_vc_position=False, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        # Require VC position (only required when the Device is a VirtualChassis member)
+        self.fields['vc_position'].required = True
+
+        # Add bootstrap classes to form elements.
+        self.fields['vc_position'].widget.attrs = {'class': 'form-control'}
+        self.fields['vc_priority'].widget.attrs = {'class': 'form-control'}
+
+        # Validation of vc_position is optional. This is only required when adding a new member to an existing
+        # VirtualChassis. Otherwise, vc_position validation is handled by BaseVCMemberFormSet.
+        self.validate_vc_position = validate_vc_position
+
+    def clean_vc_position(self):
+        vc_position = self.cleaned_data['vc_position']
+
+        if self.validate_vc_position:
+            conflicting_members = Device.objects.filter(
+                virtual_chassis=self.instance.virtual_chassis,
+                vc_position=vc_position
+            )
+            if conflicting_members.exists():
+                raise forms.ValidationError(
+                    'A virtual chassis member already exists in position {}.'.format(vc_position)
+                )
+
+        return vc_position
+
+
+class VCMemberSelectForm(BootstrapMixin, forms.Form):
+    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',
+        }
+    )
+    rack = DynamicModelChoiceField(
+        queryset=Rack.objects.all(),
+        required=False,
+        null_option='None',
+        query_params={
+            'site_id': '$site'
+        }
+    )
+    device = DynamicModelChoiceField(
+        queryset=Device.objects.all(),
+        query_params={
+            'site_id': '$site',
+            'rack_id': '$rack',
+            'virtual_chassis_id': 'null',
+        }
+    )
+
+    def clean_device(self):
+        device = self.cleaned_data['device']
+        if device.virtual_chassis is not None:
+            raise forms.ValidationError(
+                f"Device {device} is already assigned to a virtual chassis."
+            )
+        return device
+
+
+#
+# Device component templates
+#
+
+
+class ConsolePortTemplateForm(BootstrapMixin, forms.ModelForm):
+
+    class Meta:
+        model = ConsolePortTemplate
+        fields = [
+            'device_type', 'name', 'label', 'type', 'description',
+        ]
+        widgets = {
+            'device_type': forms.HiddenInput(),
+        }
+
+
+class ConsoleServerPortTemplateForm(BootstrapMixin, forms.ModelForm):
+
+    class Meta:
+        model = ConsoleServerPortTemplate
+        fields = [
+            'device_type', 'name', 'label', 'type', 'description',
+        ]
+        widgets = {
+            'device_type': forms.HiddenInput(),
+        }
+
+
+class PowerPortTemplateForm(BootstrapMixin, forms.ModelForm):
+
+    class Meta:
+        model = PowerPortTemplate
+        fields = [
+            'device_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description',
+        ]
+        widgets = {
+            'device_type': forms.HiddenInput(),
+        }
+
+
+class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm):
+
+    class Meta:
+        model = PowerOutletTemplate
+        fields = [
+            'device_type', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description',
+        ]
+        widgets = {
+            'device_type': forms.HiddenInput(),
+        }
+
+    def __init__(self, *args, **kwargs):
+
+        super().__init__(*args, **kwargs)
+
+        # Limit power_port choices to current DeviceType
+        if hasattr(self.instance, 'device_type'):
+            self.fields['power_port'].queryset = PowerPortTemplate.objects.filter(
+                device_type=self.instance.device_type
+            )
+
+
+class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm):
+
+    class Meta:
+        model = InterfaceTemplate
+        fields = [
+            'device_type', 'name', 'label', 'type', 'mgmt_only', 'description',
+        ]
+        widgets = {
+            'device_type': forms.HiddenInput(),
+            'type': StaticSelect(),
+        }
+
+
+class FrontPortTemplateForm(BootstrapMixin, forms.ModelForm):
+
+    class Meta:
+        model = FrontPortTemplate
+        fields = [
+            'device_type', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description',
+        ]
+        widgets = {
+            'device_type': forms.HiddenInput(),
+            'rear_port': StaticSelect(),
+        }
+
+    def __init__(self, *args, **kwargs):
+
+        super().__init__(*args, **kwargs)
+
+        # Limit rear_port choices to current DeviceType
+        if hasattr(self.instance, 'device_type'):
+            self.fields['rear_port'].queryset = RearPortTemplate.objects.filter(
+                device_type=self.instance.device_type
+            )
+
+
+class RearPortTemplateForm(BootstrapMixin, forms.ModelForm):
+
+    class Meta:
+        model = RearPortTemplate
+        fields = [
+            'device_type', 'name', 'label', 'type', 'color', 'positions', 'description',
+        ]
+        widgets = {
+            'device_type': forms.HiddenInput(),
+            'type': StaticSelect(),
+        }
+
+
+class DeviceBayTemplateForm(BootstrapMixin, forms.ModelForm):
+
+    class Meta:
+        model = DeviceBayTemplate
+        fields = [
+            'device_type', 'name', 'label', 'description',
+        ]
+        widgets = {
+            'device_type': forms.HiddenInput(),
+        }
+
+
+#
+# Device components
+#
+
+class ConsolePortForm(BootstrapMixin, CustomFieldModelForm):
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
+        required=False
+    )
+
+    class Meta:
+        model = ConsolePort
+        fields = [
+            'device', 'name', 'label', 'type', 'speed', 'mark_connected', 'description', 'tags',
+        ]
+        widgets = {
+            'device': forms.HiddenInput(),
+        }
+
+
+class ConsoleServerPortForm(BootstrapMixin, CustomFieldModelForm):
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
+        required=False
+    )
+
+    class Meta:
+        model = ConsoleServerPort
+        fields = [
+            'device', 'name', 'label', 'type', 'speed', 'mark_connected', 'description', 'tags',
+        ]
+        widgets = {
+            'device': forms.HiddenInput(),
+        }
+
+
+class PowerPortForm(BootstrapMixin, CustomFieldModelForm):
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
+        required=False
+    )
+
+    class Meta:
+        model = PowerPort
+        fields = [
+            'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'mark_connected', 'description',
+            'tags',
+        ]
+        widgets = {
+            'device': forms.HiddenInput(),
+        }
+
+
+class PowerOutletForm(BootstrapMixin, CustomFieldModelForm):
+    power_port = forms.ModelChoiceField(
+        queryset=PowerPort.objects.all(),
+        required=False
+    )
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
+        required=False
+    )
+
+    class Meta:
+        model = PowerOutlet
+        fields = [
+            'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'mark_connected', 'description', 'tags',
+        ]
+        widgets = {
+            'device': forms.HiddenInput(),
+        }
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        # Limit power_port choices to the local device
+        if hasattr(self.instance, 'device'):
+            self.fields['power_port'].queryset = PowerPort.objects.filter(
+                device=self.instance.device
+            )
+
+
+class InterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm):
+    parent = DynamicModelChoiceField(
+        queryset=Interface.objects.all(),
+        required=False,
+        label='Parent interface'
+    )
+    lag = DynamicModelChoiceField(
+        queryset=Interface.objects.all(),
+        required=False,
+        label='LAG interface',
+        query_params={
+            'type': 'lag',
+        }
+    )
+    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 = Interface
+        fields = [
+            'device', 'name', 'label', 'type', 'enabled', 'parent', 'lag', 'mac_address', 'mtu', 'mgmt_only',
+            'mark_connected', 'description', 'mode', 'untagged_vlan', 'tagged_vlans', 'tags',
+        ]
+        widgets = {
+            'device': forms.HiddenInput(),
+            'type': StaticSelect(),
+            'mode': StaticSelect(),
+        }
+        labels = {
+            'mode': '802.1Q Mode',
+        }
+        help_texts = {
+            'mode': INTERFACE_MODE_HELP_TEXT,
+        }
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        device = Device.objects.get(pk=self.data['device']) if self.is_bound else self.instance.device
+
+        # Restrict parent/LAG interface assignment by device/VC
+        self.fields['parent'].widget.add_query_param('device_id', device.pk)
+        if device.virtual_chassis and device.virtual_chassis.master:
+            # Get available LAG interfaces by VirtualChassis master
+            self.fields['lag'].widget.add_query_param('device_id', device.virtual_chassis.master.pk)
+        else:
+            self.fields['lag'].widget.add_query_param('device_id', device.pk)
+
+        # Limit VLAN choices by device
+        self.fields['untagged_vlan'].widget.add_query_param('available_on_device', device.pk)
+        self.fields['tagged_vlans'].widget.add_query_param('available_on_device', device.pk)
+
+
+class FrontPortForm(BootstrapMixin, CustomFieldModelForm):
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
+        required=False
+    )
+
+    class Meta:
+        model = FrontPort
+        fields = [
+            'device', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'mark_connected',
+            'description', 'tags',
+        ]
+        widgets = {
+            'device': forms.HiddenInput(),
+            'type': StaticSelect(),
+            'rear_port': StaticSelect(),
+        }
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        # Limit RearPort choices to the local device
+        if hasattr(self.instance, 'device'):
+            self.fields['rear_port'].queryset = self.fields['rear_port'].queryset.filter(
+                device=self.instance.device
+            )
+
+
+class RearPortForm(BootstrapMixin, CustomFieldModelForm):
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
+        required=False
+    )
+
+    class Meta:
+        model = RearPort
+        fields = [
+            'device', 'name', 'label', 'type', 'color', 'positions', 'mark_connected', 'description', 'tags',
+        ]
+        widgets = {
+            'device': forms.HiddenInput(),
+            'type': StaticSelect(),
+        }
+
+
+class DeviceBayForm(BootstrapMixin, CustomFieldModelForm):
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
+        required=False
+    )
+
+    class Meta:
+        model = DeviceBay
+        fields = [
+            'device', 'name', 'label', 'description', 'tags',
+        ]
+        widgets = {
+            'device': forms.HiddenInput(),
+        }
+
+
+class PopulateDeviceBayForm(BootstrapMixin, forms.Form):
+    installed_device = forms.ModelChoiceField(
+        queryset=Device.objects.all(),
+        label='Child Device',
+        help_text="Child devices must first be created and assigned to the site/rack of the parent device.",
+        widget=StaticSelect(),
+    )
+
+    def __init__(self, device_bay, *args, **kwargs):
+
+        super().__init__(*args, **kwargs)
+
+        self.fields['installed_device'].queryset = Device.objects.filter(
+            site=device_bay.device.site,
+            rack=device_bay.device.rack,
+            parent_bay__isnull=True,
+            device_type__u_height=0,
+            device_type__subdevice_role=SubdeviceRoleChoices.ROLE_CHILD
+        ).exclude(pk=device_bay.device.pk)
+
+
+class InventoryItemForm(BootstrapMixin, CustomFieldModelForm):
+    device = DynamicModelChoiceField(
+        queryset=Device.objects.all()
+    )
+    parent = DynamicModelChoiceField(
+        queryset=InventoryItem.objects.all(),
+        required=False,
+        query_params={
+            'device_id': '$device'
+        }
+    )
+    manufacturer = DynamicModelChoiceField(
+        queryset=Manufacturer.objects.all(),
+        required=False
+    )
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
+        required=False
+    )
+
+    class Meta:
+        model = InventoryItem
+        fields = [
+            'device', 'parent', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description',
+            'tags',
+        ]

+ 614 - 0
netbox/dcim/forms/object_create.py

@@ -0,0 +1,614 @@
+from django import forms
+
+from dcim.choices import *
+from dcim.constants import *
+from dcim.models import *
+from extras.forms import CustomFieldModelForm, CustomFieldsMixin
+from extras.models import Tag
+from ipam.models import VLAN
+from utilities.forms import (
+    add_blank_choice, BootstrapMixin, ColorField, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
+    ExpandableNameField, StaticSelect,
+)
+from .common import InterfaceCommonForm
+
+__all__ = (
+    'ConsolePortCreateForm',
+    'ConsolePortTemplateCreateForm',
+    'ConsoleServerPortCreateForm',
+    'ConsoleServerPortTemplateCreateForm',
+    'DeviceBayCreateForm',
+    'DeviceBayTemplateCreateForm',
+    'FrontPortCreateForm',
+    'FrontPortTemplateCreateForm',
+    'InterfaceCreateForm',
+    'InterfaceTemplateCreateForm',
+    'InventoryItemCreateForm',
+    'PowerOutletCreateForm',
+    'PowerOutletTemplateCreateForm',
+    'PowerPortCreateForm',
+    'PowerPortTemplateCreateForm',
+    'RearPortCreateForm',
+    'RearPortTemplateCreateForm',
+    'VirtualChassisCreateForm',
+)
+
+
+class ComponentForm(forms.Form):
+    """
+    Subclass this form when facilitating the creation of one or more device component or component templates based on
+    a name pattern.
+    """
+    name_pattern = ExpandableNameField(
+        label='Name'
+    )
+    label_pattern = ExpandableNameField(
+        label='Label',
+        required=False,
+        help_text='Alphanumeric ranges are supported. (Must match the number of names being created.)'
+    )
+
+    def clean(self):
+        super().clean()
+
+        # Validate that the number of components being created from both the name_pattern and label_pattern are equal
+        if self.cleaned_data['label_pattern']:
+            name_pattern_count = len(self.cleaned_data['name_pattern'])
+            label_pattern_count = len(self.cleaned_data['label_pattern'])
+            if name_pattern_count != label_pattern_count:
+                raise forms.ValidationError({
+                    'label_pattern': f'The provided name pattern will create {name_pattern_count} components, however '
+                                     f'{label_pattern_count} labels will be generated. These counts must match.'
+                }, code='label_pattern_mismatch')
+
+
+class VirtualChassisCreateForm(BootstrapMixin, CustomFieldModelForm):
+    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',
+        }
+    )
+    rack = DynamicModelChoiceField(
+        queryset=Rack.objects.all(),
+        required=False,
+        null_option='None',
+        query_params={
+            'site_id': '$site'
+        }
+    )
+    members = DynamicModelMultipleChoiceField(
+        queryset=Device.objects.all(),
+        required=False,
+        query_params={
+            'site_id': '$site',
+            'rack_id': '$rack',
+        }
+    )
+    initial_position = forms.IntegerField(
+        initial=1,
+        required=False,
+        help_text='Position of the first member device. Increases by one for each additional member.'
+    )
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
+        required=False
+    )
+
+    class Meta:
+        model = VirtualChassis
+        fields = [
+            'name', 'domain', 'region', 'site_group', 'site', 'rack', 'members', 'initial_position', 'tags',
+        ]
+
+    def save(self, *args, **kwargs):
+        instance = super().save(*args, **kwargs)
+
+        # Assign VC members
+        if instance.pk:
+            initial_position = self.cleaned_data.get('initial_position') or 1
+            for i, member in enumerate(self.cleaned_data['members'], start=initial_position):
+                member.virtual_chassis = instance
+                member.vc_position = i
+                member.save()
+
+        return instance
+
+
+#
+# Component templates
+#
+
+class ComponentTemplateCreateForm(BootstrapMixin, ComponentForm):
+    """
+    Base form for the creation of device component templates (subclassed from ComponentTemplateModel).
+    """
+    manufacturer = DynamicModelChoiceField(
+        queryset=Manufacturer.objects.all(),
+        required=False,
+        initial_params={
+            'device_types': 'device_type'
+        }
+    )
+    device_type = DynamicModelChoiceField(
+        queryset=DeviceType.objects.all(),
+        query_params={
+            'manufacturer_id': '$manufacturer'
+        }
+    )
+    description = forms.CharField(
+        required=False
+    )
+
+
+class ConsolePortTemplateCreateForm(ComponentTemplateCreateForm):
+    type = forms.ChoiceField(
+        choices=add_blank_choice(ConsolePortTypeChoices),
+        widget=StaticSelect()
+    )
+    field_order = ('manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'type', 'description')
+
+
+class ConsoleServerPortTemplateCreateForm(ComponentTemplateCreateForm):
+    type = forms.ChoiceField(
+        choices=add_blank_choice(ConsolePortTypeChoices),
+        widget=StaticSelect()
+    )
+    field_order = ('manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'type', 'description')
+
+
+class PowerPortTemplateCreateForm(ComponentTemplateCreateForm):
+    type = forms.ChoiceField(
+        choices=add_blank_choice(PowerPortTypeChoices),
+        required=False
+    )
+    maximum_draw = forms.IntegerField(
+        min_value=1,
+        required=False,
+        help_text="Maximum power draw (watts)"
+    )
+    allocated_draw = forms.IntegerField(
+        min_value=1,
+        required=False,
+        help_text="Allocated power draw (watts)"
+    )
+    field_order = (
+        'manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'type', 'maximum_draw', 'allocated_draw',
+        'description',
+    )
+
+
+class PowerOutletTemplateCreateForm(ComponentTemplateCreateForm):
+    type = forms.ChoiceField(
+        choices=add_blank_choice(PowerOutletTypeChoices),
+        required=False
+    )
+    power_port = forms.ModelChoiceField(
+        queryset=PowerPortTemplate.objects.all(),
+        required=False
+    )
+    feed_leg = forms.ChoiceField(
+        choices=add_blank_choice(PowerOutletFeedLegChoices),
+        required=False,
+        widget=StaticSelect()
+    )
+    field_order = (
+        'manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'type', 'power_port', 'feed_leg',
+        'description',
+    )
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        # Limit power_port choices to current DeviceType
+        device_type = DeviceType.objects.get(
+            pk=self.initial.get('device_type') or self.data.get('device_type')
+        )
+        self.fields['power_port'].queryset = PowerPortTemplate.objects.filter(
+            device_type=device_type
+        )
+
+
+class InterfaceTemplateCreateForm(ComponentTemplateCreateForm):
+    type = forms.ChoiceField(
+        choices=InterfaceTypeChoices,
+        widget=StaticSelect()
+    )
+    mgmt_only = forms.BooleanField(
+        required=False,
+        label='Management only'
+    )
+    field_order = ('manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'type', 'mgmt_only', 'description')
+
+
+class FrontPortTemplateCreateForm(ComponentTemplateCreateForm):
+    type = forms.ChoiceField(
+        choices=PortTypeChoices,
+        widget=StaticSelect()
+    )
+    color = ColorField(
+        required=False
+    )
+    rear_port_set = forms.MultipleChoiceField(
+        choices=[],
+        label='Rear ports',
+        help_text='Select one rear port assignment for each front port being created.',
+    )
+    field_order = (
+        'manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'type', 'color', 'rear_port_set', 'description',
+    )
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        device_type = DeviceType.objects.get(
+            pk=self.initial.get('device_type') or self.data.get('device_type')
+        )
+
+        # Determine which rear port positions are occupied. These will be excluded from the list of available mappings.
+        occupied_port_positions = [
+            (front_port.rear_port_id, front_port.rear_port_position)
+            for front_port in device_type.frontporttemplates.all()
+        ]
+
+        # Populate rear port choices
+        choices = []
+        rear_ports = RearPortTemplate.objects.filter(device_type=device_type)
+        for rear_port in rear_ports:
+            for i in range(1, rear_port.positions + 1):
+                if (rear_port.pk, i) not in occupied_port_positions:
+                    choices.append(
+                        ('{}:{}'.format(rear_port.pk, i), '{}:{}'.format(rear_port.name, i))
+                    )
+        self.fields['rear_port_set'].choices = choices
+
+    def clean(self):
+        super().clean()
+
+        # Validate that the number of ports being created equals the number of selected (rear port, position) tuples
+        front_port_count = len(self.cleaned_data['name_pattern'])
+        rear_port_count = len(self.cleaned_data['rear_port_set'])
+        if front_port_count != rear_port_count:
+            raise forms.ValidationError({
+                'rear_port_set': 'The provided name pattern will create {} ports, however {} rear port assignments '
+                                 'were selected. These counts must match.'.format(front_port_count, rear_port_count)
+            })
+
+    def get_iterative_data(self, iteration):
+
+        # Assign rear port and position from selected set
+        rear_port, position = self.cleaned_data['rear_port_set'][iteration].split(':')
+
+        return {
+            'rear_port': int(rear_port),
+            'rear_port_position': int(position),
+        }
+
+
+class RearPortTemplateCreateForm(ComponentTemplateCreateForm):
+    type = forms.ChoiceField(
+        choices=PortTypeChoices,
+        widget=StaticSelect(),
+    )
+    color = ColorField(
+        required=False
+    )
+    positions = forms.IntegerField(
+        min_value=REARPORT_POSITIONS_MIN,
+        max_value=REARPORT_POSITIONS_MAX,
+        initial=1,
+        help_text='The number of front ports which may be mapped to each rear port'
+    )
+    field_order = (
+        'manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'type', 'color', 'positions', 'description',
+    )
+
+
+class DeviceBayTemplateCreateForm(ComponentTemplateCreateForm):
+    field_order = ('manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'description')
+
+
+#
+# Device components
+#
+
+class ComponentCreateForm(BootstrapMixin, CustomFieldsMixin, ComponentForm):
+    """
+    Base form for the creation of device components (models subclassed from ComponentModel).
+    """
+    device = DynamicModelChoiceField(
+        queryset=Device.objects.all()
+    )
+    description = forms.CharField(
+        max_length=200,
+        required=False
+    )
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
+        required=False
+    )
+
+
+class ConsolePortCreateForm(ComponentCreateForm):
+    model = ConsolePort
+    type = forms.ChoiceField(
+        choices=add_blank_choice(ConsolePortTypeChoices),
+        required=False,
+        widget=StaticSelect()
+    )
+    speed = forms.ChoiceField(
+        choices=add_blank_choice(ConsolePortSpeedChoices),
+        required=False,
+        widget=StaticSelect()
+    )
+    field_order = ('device', 'name_pattern', 'label_pattern', 'type', 'speed', 'mark_connected', 'description', 'tags')
+
+
+class ConsoleServerPortCreateForm(ComponentCreateForm):
+    model = ConsoleServerPort
+    type = forms.ChoiceField(
+        choices=add_blank_choice(ConsolePortTypeChoices),
+        required=False,
+        widget=StaticSelect()
+    )
+    speed = forms.ChoiceField(
+        choices=add_blank_choice(ConsolePortSpeedChoices),
+        required=False,
+        widget=StaticSelect()
+    )
+    field_order = ('device', 'name_pattern', 'label_pattern', 'type', 'speed', 'mark_connected', 'description', 'tags')
+
+
+class PowerPortCreateForm(ComponentCreateForm):
+    model = PowerPort
+    type = forms.ChoiceField(
+        choices=add_blank_choice(PowerPortTypeChoices),
+        required=False,
+        widget=StaticSelect()
+    )
+    maximum_draw = forms.IntegerField(
+        min_value=1,
+        required=False,
+        help_text="Maximum draw in watts"
+    )
+    allocated_draw = forms.IntegerField(
+        min_value=1,
+        required=False,
+        help_text="Allocated draw in watts"
+    )
+    field_order = (
+        'device', 'name_pattern', 'label_pattern', 'type', 'maximum_draw', 'allocated_draw', 'mark_connected',
+        'description', 'tags',
+    )
+
+
+class PowerOutletCreateForm(ComponentCreateForm):
+    model = PowerOutlet
+    type = forms.ChoiceField(
+        choices=add_blank_choice(PowerOutletTypeChoices),
+        required=False,
+        widget=StaticSelect()
+    )
+    power_port = forms.ModelChoiceField(
+        queryset=PowerPort.objects.all(),
+        required=False
+    )
+    feed_leg = forms.ChoiceField(
+        choices=add_blank_choice(PowerOutletFeedLegChoices),
+        required=False
+    )
+    field_order = (
+        'device', 'name_pattern', 'label_pattern', 'type', 'power_port', 'feed_leg', 'mark_connected', 'description',
+        'tags',
+    )
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        # Limit power_port queryset to PowerPorts which belong to the parent Device
+        device = Device.objects.get(
+            pk=self.initial.get('device') or self.data.get('device')
+        )
+        self.fields['power_port'].queryset = PowerPort.objects.filter(device=device)
+
+
+class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm):
+    model = Interface
+    type = forms.ChoiceField(
+        choices=InterfaceTypeChoices,
+        widget=StaticSelect(),
+    )
+    enabled = forms.BooleanField(
+        required=False,
+        initial=True
+    )
+    parent = DynamicModelChoiceField(
+        queryset=Interface.objects.all(),
+        required=False,
+        query_params={
+            'device_id': '$device',
+        }
+    )
+    lag = DynamicModelChoiceField(
+        queryset=Interface.objects.all(),
+        required=False,
+        query_params={
+            'device_id': '$device',
+            'type': 'lag',
+        }
+    )
+    mac_address = forms.CharField(
+        required=False,
+        label='MAC Address'
+    )
+    mgmt_only = forms.BooleanField(
+        required=False,
+        label='Management only',
+        help_text='This interface is used only for out-of-band management'
+    )
+    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
+    )
+    field_order = (
+        'device', 'name_pattern', 'label_pattern', 'type', 'enabled', 'parent', 'lag', 'mtu', 'mac_address',
+        'description', 'mgmt_only', 'mark_connected', 'mode', 'untagged_vlan', 'tagged_vlans', 'tags'
+    )
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        # Limit VLAN choices by device
+        device_id = self.initial.get('device') or self.data.get('device')
+        self.fields['untagged_vlan'].widget.add_query_param('available_on_device', device_id)
+        self.fields['tagged_vlans'].widget.add_query_param('available_on_device', device_id)
+
+
+class FrontPortCreateForm(ComponentCreateForm):
+    model = FrontPort
+    type = forms.ChoiceField(
+        choices=PortTypeChoices,
+        widget=StaticSelect(),
+    )
+    color = ColorField(
+        required=False
+    )
+    rear_port_set = forms.MultipleChoiceField(
+        choices=[],
+        label='Rear ports',
+        help_text='Select one rear port assignment for each front port being created.',
+    )
+    field_order = (
+        'device', 'name_pattern', 'label_pattern', 'type', 'color', 'rear_port_set', 'mark_connected', 'description',
+        'tags',
+    )
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        device = Device.objects.get(
+            pk=self.initial.get('device') or self.data.get('device')
+        )
+
+        # Determine which rear port positions are occupied. These will be excluded from the list of available
+        # mappings.
+        occupied_port_positions = [
+            (front_port.rear_port_id, front_port.rear_port_position)
+            for front_port in device.frontports.all()
+        ]
+
+        # Populate rear port choices
+        choices = []
+        rear_ports = RearPort.objects.filter(device=device)
+        for rear_port in rear_ports:
+            for i in range(1, rear_port.positions + 1):
+                if (rear_port.pk, i) not in occupied_port_positions:
+                    choices.append(
+                        ('{}:{}'.format(rear_port.pk, i), '{}:{}'.format(rear_port.name, i))
+                    )
+        self.fields['rear_port_set'].choices = choices
+
+    def clean(self):
+        super().clean()
+
+        # Validate that the number of ports being created equals the number of selected (rear port, position) tuples
+        front_port_count = len(self.cleaned_data['name_pattern'])
+        rear_port_count = len(self.cleaned_data['rear_port_set'])
+        if front_port_count != rear_port_count:
+            raise forms.ValidationError({
+                'rear_port_set': 'The provided name pattern will create {} ports, however {} rear port assignments '
+                                 'were selected. These counts must match.'.format(front_port_count, rear_port_count)
+            })
+
+    def get_iterative_data(self, iteration):
+
+        # Assign rear port and position from selected set
+        rear_port, position = self.cleaned_data['rear_port_set'][iteration].split(':')
+
+        return {
+            'rear_port': int(rear_port),
+            'rear_port_position': int(position),
+        }
+
+
+class RearPortCreateForm(ComponentCreateForm):
+    model = RearPort
+    type = forms.ChoiceField(
+        choices=PortTypeChoices,
+        widget=StaticSelect(),
+    )
+    color = ColorField(
+        required=False
+    )
+    positions = forms.IntegerField(
+        min_value=REARPORT_POSITIONS_MIN,
+        max_value=REARPORT_POSITIONS_MAX,
+        initial=1,
+        help_text='The number of front ports which may be mapped to each rear port'
+    )
+    field_order = (
+        'device', 'name_pattern', 'label_pattern', 'type', 'color', 'positions', 'mark_connected', 'description',
+        'tags',
+    )
+
+
+class DeviceBayCreateForm(ComponentCreateForm):
+    model = DeviceBay
+    field_order = ('device', 'name_pattern', 'label_pattern', 'description', 'tags')
+
+
+class InventoryItemCreateForm(ComponentCreateForm):
+    model = InventoryItem
+    manufacturer = DynamicModelChoiceField(
+        queryset=Manufacturer.objects.all(),
+        required=False
+    )
+    parent = DynamicModelChoiceField(
+        queryset=InventoryItem.objects.all(),
+        required=False,
+        query_params={
+            'device_id': '$device'
+        }
+    )
+    part_id = forms.CharField(
+        max_length=50,
+        required=False,
+        label='Part ID'
+    )
+    serial = forms.CharField(
+        max_length=50,
+        required=False,
+    )
+    asset_tag = forms.CharField(
+        max_length=50,
+        required=False,
+    )
+    field_order = (
+        'device', 'parent', 'name_pattern', 'label_pattern', 'manufacturer', 'part_id', 'serial', 'asset_tag',
+        'description', 'tags',
+    )

+ 148 - 0
netbox/dcim/forms/object_import.py

@@ -0,0 +1,148 @@
+from django import forms
+
+from dcim.choices import InterfaceTypeChoices, PortTypeChoices
+from dcim.models import *
+from utilities.forms import BootstrapMixin
+
+__all__ = (
+    'ConsolePortTemplateImportForm',
+    'ConsoleServerPortTemplateImportForm',
+    'DeviceBayTemplateImportForm',
+    'DeviceTypeImportForm',
+    'FrontPortTemplateImportForm',
+    'InterfaceTemplateImportForm',
+    'PowerOutletTemplateImportForm',
+    'PowerPortTemplateImportForm',
+    'RearPortTemplateImportForm',
+)
+
+
+class DeviceTypeImportForm(BootstrapMixin, forms.ModelForm):
+    manufacturer = forms.ModelChoiceField(
+        queryset=Manufacturer.objects.all(),
+        to_field_name='name'
+    )
+
+    class Meta:
+        model = DeviceType
+        fields = [
+            'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role',
+            'comments',
+        ]
+
+
+#
+# Component template import forms
+#
+
+class ComponentTemplateImportForm(BootstrapMixin, forms.ModelForm):
+
+    def __init__(self, device_type, data=None, *args, **kwargs):
+
+        # Must pass the parent DeviceType on form initialization
+        data.update({
+            'device_type': device_type.pk,
+        })
+
+        super().__init__(data, *args, **kwargs)
+
+    def clean_device_type(self):
+
+        data = self.cleaned_data['device_type']
+
+        # Limit fields referencing other components to the parent DeviceType
+        for field_name, field in self.fields.items():
+            if isinstance(field, forms.ModelChoiceField) and field_name != 'device_type':
+                field.queryset = field.queryset.filter(device_type=data)
+
+        return data
+
+
+class ConsolePortTemplateImportForm(ComponentTemplateImportForm):
+
+    class Meta:
+        model = ConsolePortTemplate
+        fields = [
+            'device_type', 'name', 'label', 'type', 'description',
+        ]
+
+
+class ConsoleServerPortTemplateImportForm(ComponentTemplateImportForm):
+
+    class Meta:
+        model = ConsoleServerPortTemplate
+        fields = [
+            'device_type', 'name', 'label', 'type', 'description',
+        ]
+
+
+class PowerPortTemplateImportForm(ComponentTemplateImportForm):
+
+    class Meta:
+        model = PowerPortTemplate
+        fields = [
+            'device_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description',
+        ]
+
+
+class PowerOutletTemplateImportForm(ComponentTemplateImportForm):
+    power_port = forms.ModelChoiceField(
+        queryset=PowerPortTemplate.objects.all(),
+        to_field_name='name',
+        required=False
+    )
+
+    class Meta:
+        model = PowerOutletTemplate
+        fields = [
+            'device_type', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description',
+        ]
+
+
+class InterfaceTemplateImportForm(ComponentTemplateImportForm):
+    type = forms.ChoiceField(
+        choices=InterfaceTypeChoices.CHOICES
+    )
+
+    class Meta:
+        model = InterfaceTemplate
+        fields = [
+            'device_type', 'name', 'label', 'type', 'mgmt_only', 'description',
+        ]
+
+
+class FrontPortTemplateImportForm(ComponentTemplateImportForm):
+    type = forms.ChoiceField(
+        choices=PortTypeChoices.CHOICES
+    )
+    rear_port = forms.ModelChoiceField(
+        queryset=RearPortTemplate.objects.all(),
+        to_field_name='name'
+    )
+
+    class Meta:
+        model = FrontPortTemplate
+        fields = [
+            'device_type', 'name', 'type', 'rear_port', 'rear_port_position', 'label', 'description',
+        ]
+
+
+class RearPortTemplateImportForm(ComponentTemplateImportForm):
+    type = forms.ChoiceField(
+        choices=PortTypeChoices.CHOICES
+    )
+
+    class Meta:
+        model = RearPortTemplate
+        fields = [
+            'device_type', 'name', 'type', 'positions', 'label', 'description',
+        ]
+
+
+class DeviceBayTemplateImportForm(ComponentTemplateImportForm):
+
+    class Meta:
+        model = DeviceBayTemplate
+        fields = [
+            'device_type', 'name', 'label', 'description',
+        ]

+ 1 - 0
netbox/dcim/tests/test_forms.py

@@ -1,5 +1,6 @@
 from django.test import TestCase
 from django.test import TestCase
 
 
+from dcim.choices import DeviceFaceChoices, DeviceStatusChoices, InterfaceTypeChoices
 from dcim.forms import *
 from dcim.forms import *
 from dcim.models import *
 from dcim.models import *
 from virtualization.models import Cluster, ClusterGroup, ClusterType
 from virtualization.models import Cluster, ClusterGroup, ClusterType

+ 1 - 2
netbox/dcim/views.py

@@ -1,11 +1,10 @@
-import logging
 from collections import OrderedDict
 from collections import OrderedDict
 
 
 from django.contrib import messages
 from django.contrib import messages
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.core.paginator import EmptyPage, PageNotAnInteger
 from django.core.paginator import EmptyPage, PageNotAnInteger
 from django.db import transaction
 from django.db import transaction
-from django.db.models import F, Prefetch
+from django.db.models import Prefetch
 from django.forms import ModelMultipleChoiceField, MultipleHiddenInput, modelformset_factory
 from django.forms import ModelMultipleChoiceField, MultipleHiddenInput, modelformset_factory
 from django.shortcuts import get_object_or_404, redirect, render
 from django.shortcuts import get_object_or_404, redirect, render
 from django.urls import reverse
 from django.urls import reverse

+ 0 - 988
netbox/extras/forms.py

@@ -1,988 +0,0 @@
-from django import forms
-from django.contrib.auth.models import User
-from django.contrib.contenttypes.models import ContentType
-from django.contrib.postgres.forms import SimpleArrayField
-from django.utils.safestring import mark_safe
-from django.utils.translation import gettext as _
-
-from dcim.models import DeviceRole, DeviceType, Platform, Region, Site, SiteGroup
-from tenancy.models import Tenant, TenantGroup
-from utilities.forms import (
-    add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorField,
-    CommentField, ContentTypeChoiceField, ContentTypeMultipleChoiceField, CSVContentTypeField, CSVModelForm,
-    CSVMultipleContentTypeField, DateTimePicker, DynamicModelMultipleChoiceField, JSONField, SlugField, StaticSelect,
-    StaticSelectMultiple, BOOLEAN_WITH_BLANK_CHOICES,
-)
-from virtualization.models import Cluster, ClusterGroup
-from .choices import *
-from .models import *
-from .utils import FeatureQuery
-
-
-#
-# Custom fields
-#
-
-class CustomFieldForm(BootstrapMixin, forms.ModelForm):
-    content_types = ContentTypeMultipleChoiceField(
-        queryset=ContentType.objects.all(),
-        limit_choices_to=FeatureQuery('custom_fields')
-    )
-
-    class Meta:
-        model = CustomField
-        fields = '__all__'
-        fieldsets = (
-            ('Custom Field', ('name', 'label', 'type', 'weight', 'required', 'description')),
-            ('Assigned Models', ('content_types',)),
-            ('Behavior', ('filter_logic',)),
-            ('Values', ('default', 'choices')),
-            ('Validation', ('validation_minimum', 'validation_maximum', 'validation_regex')),
-        )
-
-
-class CustomFieldCSVForm(CSVModelForm):
-    content_types = CSVMultipleContentTypeField(
-        queryset=ContentType.objects.all(),
-        limit_choices_to=FeatureQuery('custom_fields'),
-        help_text="One or more assigned object types"
-    )
-    choices = SimpleArrayField(
-        base_field=forms.CharField(),
-        required=False,
-        help_text='Comma-separated list of field choices'
-    )
-
-    class Meta:
-        model = CustomField
-        fields = (
-            'name', 'label', 'type', 'content_types', 'required', 'description', 'weight', 'filter_logic', 'default',
-            'choices', 'weight',
-        )
-
-
-class CustomFieldBulkEditForm(BootstrapMixin, BulkEditForm):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=CustomField.objects.all(),
-        widget=forms.MultipleHiddenInput
-    )
-    description = forms.CharField(
-        required=False
-    )
-    required = forms.NullBooleanField(
-        required=False,
-        widget=BulkEditNullBooleanSelect()
-    )
-    weight = forms.IntegerField(
-        required=False
-    )
-
-    class Meta:
-        nullable_fields = []
-
-
-class CustomFieldFilterForm(BootstrapMixin, forms.Form):
-    field_groups = [
-        ['q'],
-        ['type', 'content_types'],
-        ['weight', 'required'],
-    ]
-    q = forms.CharField(
-        required=False,
-        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
-        label=_('Search')
-    )
-    content_types = ContentTypeMultipleChoiceField(
-        queryset=ContentType.objects.all(),
-        limit_choices_to=FeatureQuery('custom_fields'),
-        required=False
-    )
-    type = forms.MultipleChoiceField(
-        choices=CustomFieldTypeChoices,
-        required=False,
-        widget=StaticSelectMultiple(),
-        label=_('Field type')
-    )
-    weight = forms.IntegerField(
-        required=False
-    )
-    required = forms.NullBooleanField(
-        required=False,
-        widget=StaticSelect(
-            choices=BOOLEAN_WITH_BLANK_CHOICES
-        )
-    )
-
-
-#
-# Custom links
-#
-
-class CustomLinkForm(BootstrapMixin, forms.ModelForm):
-    content_type = ContentTypeChoiceField(
-        queryset=ContentType.objects.all(),
-        limit_choices_to=FeatureQuery('custom_links')
-    )
-
-    class Meta:
-        model = CustomLink
-        fields = '__all__'
-        fieldsets = (
-            ('Custom Link', ('name', 'content_type', 'weight', 'group_name', 'button_class', 'new_window')),
-            ('Templates', ('link_text', 'link_url')),
-        )
-        widgets = {
-            'link_text': forms.Textarea(attrs={'class': 'font-monospace'}),
-            'link_url': forms.Textarea(attrs={'class': 'font-monospace'}),
-        }
-        help_texts = {
-            'link_text': 'Jinja2 template code for the link text. Reference the object as <code>{{ obj }}</code>. '
-                         'Links which render as empty text will not be displayed.',
-            'link_url': 'Jinja2 template code for the link URL. Reference the object as <code>{{ obj }}</code>.',
-        }
-
-
-class CustomLinkCSVForm(CSVModelForm):
-    content_type = CSVContentTypeField(
-        queryset=ContentType.objects.all(),
-        limit_choices_to=FeatureQuery('custom_links'),
-        help_text="Assigned object type"
-    )
-
-    class Meta:
-        model = CustomLink
-        fields = (
-            'name', 'content_type', 'weight', 'group_name', 'button_class', 'new_window', 'link_text', 'link_url',
-        )
-
-
-class CustomLinkBulkEditForm(BootstrapMixin, BulkEditForm):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=CustomLink.objects.all(),
-        widget=forms.MultipleHiddenInput
-    )
-    content_type = ContentTypeChoiceField(
-        queryset=ContentType.objects.all(),
-        limit_choices_to=FeatureQuery('custom_fields'),
-        required=False
-    )
-    new_window = forms.NullBooleanField(
-        required=False,
-        widget=BulkEditNullBooleanSelect()
-    )
-    weight = forms.IntegerField(
-        required=False
-    )
-    button_class = forms.ChoiceField(
-        choices=CustomLinkButtonClassChoices,
-        required=False,
-        widget=StaticSelect()
-    )
-
-    class Meta:
-        nullable_fields = []
-
-
-class CustomLinkFilterForm(BootstrapMixin, forms.Form):
-    field_groups = [
-        ['q'],
-        ['content_type', 'weight', 'new_window'],
-    ]
-    q = forms.CharField(
-        required=False,
-        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
-        label=_('Search')
-    )
-    content_type = ContentTypeChoiceField(
-        queryset=ContentType.objects.all(),
-        limit_choices_to=FeatureQuery('custom_fields'),
-        required=False
-    )
-    weight = forms.IntegerField(
-        required=False
-    )
-    new_window = forms.NullBooleanField(
-        required=False,
-        widget=StaticSelect(
-            choices=BOOLEAN_WITH_BLANK_CHOICES
-        )
-    )
-
-
-#
-# Export templates
-#
-
-class ExportTemplateForm(BootstrapMixin, forms.ModelForm):
-    content_type = ContentTypeChoiceField(
-        queryset=ContentType.objects.all(),
-        limit_choices_to=FeatureQuery('custom_links')
-    )
-
-    class Meta:
-        model = ExportTemplate
-        fields = '__all__'
-        fieldsets = (
-            ('Custom Link', ('name', 'content_type', 'description')),
-            ('Template', ('template_code',)),
-            ('Rendering', ('mime_type', 'file_extension', 'as_attachment')),
-        )
-        widgets = {
-            'template_code': forms.Textarea(attrs={'class': 'font-monospace'}),
-        }
-
-
-class ExportTemplateCSVForm(CSVModelForm):
-    content_type = CSVContentTypeField(
-        queryset=ContentType.objects.all(),
-        limit_choices_to=FeatureQuery('export_templates'),
-        help_text="Assigned object type"
-    )
-
-    class Meta:
-        model = ExportTemplate
-        fields = (
-            'name', 'content_type', 'description', 'mime_type', 'file_extension', 'as_attachment', 'template_code',
-        )
-
-
-class ExportTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=ExportTemplate.objects.all(),
-        widget=forms.MultipleHiddenInput
-    )
-    content_type = ContentTypeChoiceField(
-        queryset=ContentType.objects.all(),
-        limit_choices_to=FeatureQuery('custom_fields'),
-        required=False
-    )
-    description = forms.CharField(
-        max_length=200,
-        required=False
-    )
-    mime_type = forms.CharField(
-        max_length=50,
-        required=False
-    )
-    file_extension = forms.CharField(
-        max_length=15,
-        required=False
-    )
-    as_attachment = forms.NullBooleanField(
-        required=False,
-        widget=BulkEditNullBooleanSelect()
-    )
-
-    class Meta:
-        nullable_fields = ['description', 'mime_type', 'file_extension']
-
-
-class ExportTemplateFilterForm(BootstrapMixin, forms.Form):
-    field_groups = [
-        ['q'],
-        ['content_type', 'mime_type', 'file_extension', 'as_attachment'],
-    ]
-    q = forms.CharField(
-        required=False,
-        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
-        label=_('Search')
-    )
-    content_type = ContentTypeChoiceField(
-        queryset=ContentType.objects.all(),
-        limit_choices_to=FeatureQuery('custom_fields'),
-        required=False
-    )
-    mime_type = forms.CharField(
-        required=False,
-        label=_('MIME type')
-    )
-    file_extension = forms.CharField(
-        required=False
-    )
-    as_attachment = forms.NullBooleanField(
-        required=False,
-        widget=StaticSelect(
-            choices=BOOLEAN_WITH_BLANK_CHOICES
-        )
-    )
-
-
-#
-# Webhooks
-#
-
-class WebhookForm(BootstrapMixin, forms.ModelForm):
-    content_types = ContentTypeMultipleChoiceField(
-        queryset=ContentType.objects.all(),
-        limit_choices_to=FeatureQuery('webhooks')
-    )
-
-    class Meta:
-        model = Webhook
-        fields = '__all__'
-        fieldsets = (
-            ('Webhook', ('name', 'enabled')),
-            ('Assigned Models', ('content_types',)),
-            ('Events', ('type_create', 'type_update', 'type_delete')),
-            ('HTTP Request', (
-                'payload_url', 'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret',
-            )),
-            ('SSL', ('ssl_verification', 'ca_file_path')),
-        )
-        widgets = {
-            'additional_headers': forms.Textarea(attrs={'class': 'font-monospace'}),
-            'body_template': forms.Textarea(attrs={'class': 'font-monospace'}),
-        }
-
-
-class WebhookCSVForm(CSVModelForm):
-    content_types = CSVMultipleContentTypeField(
-        queryset=ContentType.objects.all(),
-        limit_choices_to=FeatureQuery('webhooks'),
-        help_text="One or more assigned object types"
-    )
-
-    class Meta:
-        model = Webhook
-        fields = (
-            'name', 'enabled', 'content_types', 'type_create', 'type_update', 'type_delete', 'payload_url',
-            'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret', 'ssl_verification',
-            'ca_file_path'
-        )
-
-
-class WebhookBulkEditForm(BootstrapMixin, BulkEditForm):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=Webhook.objects.all(),
-        widget=forms.MultipleHiddenInput
-    )
-    enabled = forms.NullBooleanField(
-        required=False,
-        widget=BulkEditNullBooleanSelect()
-    )
-    type_create = forms.NullBooleanField(
-        required=False,
-        widget=BulkEditNullBooleanSelect()
-    )
-    type_update = forms.NullBooleanField(
-        required=False,
-        widget=BulkEditNullBooleanSelect()
-    )
-    type_delete = forms.NullBooleanField(
-        required=False,
-        widget=BulkEditNullBooleanSelect()
-    )
-    http_method = forms.ChoiceField(
-        choices=WebhookHttpMethodChoices,
-        required=False
-    )
-    payload_url = forms.CharField(
-        required=False
-    )
-    ssl_verification = forms.NullBooleanField(
-        required=False,
-        widget=BulkEditNullBooleanSelect()
-    )
-    secret = forms.CharField(
-        required=False
-    )
-    ca_file_path = forms.CharField(
-        required=False
-    )
-
-    class Meta:
-        nullable_fields = ['secret', 'ca_file_path']
-
-
-class WebhookFilterForm(BootstrapMixin, forms.Form):
-    field_groups = [
-        ['q'],
-        ['content_types', 'http_method', 'enabled'],
-        ['type_create', 'type_update', 'type_delete'],
-    ]
-    q = forms.CharField(
-        required=False,
-        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
-        label=_('Search')
-    )
-    content_types = ContentTypeMultipleChoiceField(
-        queryset=ContentType.objects.all(),
-        limit_choices_to=FeatureQuery('custom_fields'),
-        required=False
-    )
-    http_method = forms.MultipleChoiceField(
-        choices=WebhookHttpMethodChoices,
-        required=False,
-        widget=StaticSelectMultiple(),
-        label=_('HTTP method')
-    )
-    enabled = forms.NullBooleanField(
-        required=False,
-        widget=StaticSelect(
-            choices=BOOLEAN_WITH_BLANK_CHOICES
-        )
-    )
-    type_create = forms.NullBooleanField(
-        required=False,
-        widget=StaticSelect(
-            choices=BOOLEAN_WITH_BLANK_CHOICES
-        )
-    )
-    type_update = forms.NullBooleanField(
-        required=False,
-        widget=StaticSelect(
-            choices=BOOLEAN_WITH_BLANK_CHOICES
-        )
-    )
-    type_delete = forms.NullBooleanField(
-        required=False,
-        widget=StaticSelect(
-            choices=BOOLEAN_WITH_BLANK_CHOICES
-        )
-    )
-
-
-#
-# Custom field models
-#
-
-class CustomFieldsMixin:
-    """
-    Extend a Form to include custom field support.
-    """
-    def __init__(self, *args, **kwargs):
-        self.custom_fields = []
-
-        super().__init__(*args, **kwargs)
-
-        self._append_customfield_fields()
-
-    def _get_content_type(self):
-        """
-        Return the ContentType of the form's model.
-        """
-        if not hasattr(self, 'model'):
-            raise NotImplementedError(f"{self.__class__.__name__} must specify a model class.")
-        return ContentType.objects.get_for_model(self.model)
-
-    def _get_form_field(self, customfield):
-        return customfield.to_form_field()
-
-    def _append_customfield_fields(self):
-        """
-        Append form fields for all CustomFields assigned to this object type.
-        """
-        content_type = self._get_content_type()
-
-        # Append form fields; assign initial values if modifying and existing object
-        for customfield in CustomField.objects.filter(content_types=content_type):
-            field_name = f'cf_{customfield.name}'
-            self.fields[field_name] = self._get_form_field(customfield)
-
-            # Annotate the field in the list of CustomField form fields
-            self.custom_fields.append(field_name)
-
-
-class CustomFieldModelForm(CustomFieldsMixin, forms.ModelForm):
-    """
-    Extend ModelForm to include custom field support.
-    """
-    def _get_content_type(self):
-        return ContentType.objects.get_for_model(self._meta.model)
-
-    def _get_form_field(self, customfield):
-        if self.instance.pk:
-            form_field = customfield.to_form_field(set_initial=False)
-            form_field.initial = self.instance.custom_field_data.get(customfield.name, None)
-            return form_field
-
-        return customfield.to_form_field()
-
-    def clean(self):
-
-        # Save custom field data on instance
-        for cf_name in self.custom_fields:
-            key = cf_name[3:]  # Strip "cf_" from field name
-            value = self.cleaned_data.get(cf_name)
-            empty_values = self.fields[cf_name].empty_values
-            # Convert "empty" values to null
-            self.instance.custom_field_data[key] = value if value not in empty_values else None
-
-        return super().clean()
-
-
-class CustomFieldModelCSVForm(CSVModelForm, CustomFieldModelForm):
-
-    def _get_form_field(self, customfield):
-        return customfield.to_form_field(for_csv_import=True)
-
-
-class CustomFieldModelBulkEditForm(BulkEditForm):
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-
-        self.custom_fields = []
-        self.obj_type = ContentType.objects.get_for_model(self.model)
-
-        # Add all applicable CustomFields to the form
-        custom_fields = CustomField.objects.filter(content_types=self.obj_type)
-        for cf in custom_fields:
-            # Annotate non-required custom fields as nullable
-            if not cf.required:
-                self.nullable_fields.append(cf.name)
-            self.fields[cf.name] = cf.to_form_field(set_initial=False, enforce_required=False)
-            # Annotate this as a custom field
-            self.custom_fields.append(cf.name)
-
-
-class CustomFieldModelFilterForm(forms.Form):
-
-    def __init__(self, *args, **kwargs):
-
-        self.obj_type = ContentType.objects.get_for_model(self.model)
-
-        super().__init__(*args, **kwargs)
-
-        # Add all applicable CustomFields to the form
-        self.custom_field_filters = []
-        custom_fields = CustomField.objects.filter(content_types=self.obj_type).exclude(
-            filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED
-        )
-        for cf in custom_fields:
-            field_name = 'cf_{}'.format(cf.name)
-            self.fields[field_name] = cf.to_form_field(set_initial=True, enforce_required=False)
-            self.custom_field_filters.append(field_name)
-
-
-#
-# Tags
-#
-
-class TagForm(BootstrapMixin, forms.ModelForm):
-    slug = SlugField()
-
-    class Meta:
-        model = Tag
-        fields = [
-            'name', 'slug', 'color', 'description'
-        ]
-        fieldsets = (
-            ('Tag', ('name', 'slug', 'color', 'description')),
-        )
-
-
-class TagCSVForm(CSVModelForm):
-    slug = SlugField()
-
-    class Meta:
-        model = Tag
-        fields = ('name', 'slug', 'color', 'description')
-        help_texts = {
-            'color': mark_safe('RGB color in hexadecimal (e.g. <code>00ff00</code>)'),
-        }
-
-
-class AddRemoveTagsForm(forms.Form):
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-
-        # Add add/remove tags fields
-        self.fields['add_tags'] = DynamicModelMultipleChoiceField(
-            queryset=Tag.objects.all(),
-            required=False
-        )
-        self.fields['remove_tags'] = DynamicModelMultipleChoiceField(
-            queryset=Tag.objects.all(),
-            required=False
-        )
-
-
-class TagFilterForm(BootstrapMixin, forms.Form):
-    model = Tag
-    q = forms.CharField(
-        required=False,
-        label=_('Search')
-    )
-    content_type_id = ContentTypeMultipleChoiceField(
-        queryset=ContentType.objects.filter(FeatureQuery('tags').get_query()),
-        required=False,
-        label=_('Tagged object type')
-    )
-
-
-class TagBulkEditForm(BootstrapMixin, BulkEditForm):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=Tag.objects.all(),
-        widget=forms.MultipleHiddenInput
-    )
-    color = ColorField(
-        required=False
-    )
-    description = forms.CharField(
-        max_length=200,
-        required=False
-    )
-
-    class Meta:
-        nullable_fields = ['description']
-
-
-#
-# Config contexts
-#
-
-class ConfigContextForm(BootstrapMixin, forms.ModelForm):
-    regions = DynamicModelMultipleChoiceField(
-        queryset=Region.objects.all(),
-        required=False
-    )
-    site_groups = DynamicModelMultipleChoiceField(
-        queryset=SiteGroup.objects.all(),
-        required=False
-    )
-    sites = DynamicModelMultipleChoiceField(
-        queryset=Site.objects.all(),
-        required=False
-    )
-    device_types = DynamicModelMultipleChoiceField(
-        queryset=DeviceType.objects.all(),
-        required=False
-    )
-    roles = DynamicModelMultipleChoiceField(
-        queryset=DeviceRole.objects.all(),
-        required=False
-    )
-    platforms = DynamicModelMultipleChoiceField(
-        queryset=Platform.objects.all(),
-        required=False
-    )
-    cluster_groups = DynamicModelMultipleChoiceField(
-        queryset=ClusterGroup.objects.all(),
-        required=False
-    )
-    clusters = DynamicModelMultipleChoiceField(
-        queryset=Cluster.objects.all(),
-        required=False
-    )
-    tenant_groups = DynamicModelMultipleChoiceField(
-        queryset=TenantGroup.objects.all(),
-        required=False
-    )
-    tenants = DynamicModelMultipleChoiceField(
-        queryset=Tenant.objects.all(),
-        required=False
-    )
-    tags = DynamicModelMultipleChoiceField(
-        queryset=Tag.objects.all(),
-        required=False
-    )
-    data = JSONField(
-        label=''
-    )
-
-    class Meta:
-        model = ConfigContext
-        fields = (
-            'name', 'weight', 'description', 'is_active', 'regions', 'site_groups', 'sites', 'roles', 'device_types',
-            'platforms', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', 'data',
-        )
-
-
-class ConfigContextBulkEditForm(BootstrapMixin, BulkEditForm):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=ConfigContext.objects.all(),
-        widget=forms.MultipleHiddenInput
-    )
-    weight = forms.IntegerField(
-        required=False,
-        min_value=0
-    )
-    is_active = forms.NullBooleanField(
-        required=False,
-        widget=BulkEditNullBooleanSelect()
-    )
-    description = forms.CharField(
-        required=False,
-        max_length=100
-    )
-
-    class Meta:
-        nullable_fields = [
-            'description',
-        ]
-
-
-class ConfigContextFilterForm(BootstrapMixin, forms.Form):
-    field_groups = [
-        ['q', 'tag'],
-        ['region_id', 'site_group_id', 'site_id'],
-        ['device_type_id', 'platform_id', 'role_id'],
-        ['cluster_group_id', 'cluster_id'],
-        ['tenant_group_id', 'tenant_id']
-    ]
-    q = forms.CharField(
-        required=False,
-        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
-        label=_('Search')
-    )
-    region_id = DynamicModelMultipleChoiceField(
-        queryset=Region.objects.all(),
-        required=False,
-        label=_('Regions'),
-        fetch_trigger='open'
-    )
-    site_group_id = DynamicModelMultipleChoiceField(
-        queryset=SiteGroup.objects.all(),
-        required=False,
-        label=_('Site groups'),
-        fetch_trigger='open'
-    )
-    site_id = DynamicModelMultipleChoiceField(
-        queryset=Site.objects.all(),
-        required=False,
-        label=_('Sites'),
-        fetch_trigger='open'
-    )
-    device_type_id = DynamicModelMultipleChoiceField(
-        queryset=DeviceType.objects.all(),
-        required=False,
-        label=_('Device types'),
-        fetch_trigger='open'
-    )
-    role_id = DynamicModelMultipleChoiceField(
-        queryset=DeviceRole.objects.all(),
-        required=False,
-        label=_('Roles'),
-        fetch_trigger='open'
-    )
-    platform_id = DynamicModelMultipleChoiceField(
-        queryset=Platform.objects.all(),
-        required=False,
-        label=_('Platforms'),
-        fetch_trigger='open'
-    )
-    cluster_group_id = DynamicModelMultipleChoiceField(
-        queryset=ClusterGroup.objects.all(),
-        required=False,
-        label=_('Cluster groups'),
-        fetch_trigger='open'
-    )
-    cluster_id = DynamicModelMultipleChoiceField(
-        queryset=Cluster.objects.all(),
-        required=False,
-        label=_('Clusters'),
-        fetch_trigger='open'
-    )
-    tenant_group_id = DynamicModelMultipleChoiceField(
-        queryset=TenantGroup.objects.all(),
-        required=False,
-        label=_('Tenant groups'),
-        fetch_trigger='open'
-    )
-    tenant_id = DynamicModelMultipleChoiceField(
-        queryset=Tenant.objects.all(),
-        required=False,
-        label=_('Tenant'),
-        fetch_trigger='open'
-    )
-    tag = DynamicModelMultipleChoiceField(
-        queryset=Tag.objects.all(),
-        to_field_name='slug',
-        required=False,
-        label=_('Tags'),
-        fetch_trigger='open'
-    )
-
-
-#
-# Filter form for local config context data
-#
-
-class LocalConfigContextFilterForm(forms.Form):
-    local_context_data = forms.NullBooleanField(
-        required=False,
-        label=_('Has local config context data'),
-        widget=StaticSelect(
-            choices=BOOLEAN_WITH_BLANK_CHOICES
-        )
-    )
-
-
-#
-# Image attachments
-#
-
-class ImageAttachmentForm(BootstrapMixin, forms.ModelForm):
-
-    class Meta:
-        model = ImageAttachment
-        fields = [
-            'name', 'image',
-        ]
-
-
-#
-# Journal entries
-#
-
-class JournalEntryForm(BootstrapMixin, forms.ModelForm):
-    comments = CommentField()
-
-    kind = forms.ChoiceField(
-        choices=add_blank_choice(JournalEntryKindChoices),
-        required=False,
-        widget=StaticSelect()
-    )
-
-    class Meta:
-        model = JournalEntry
-        fields = ['assigned_object_type', 'assigned_object_id', 'kind', 'comments']
-        widgets = {
-            'assigned_object_type': forms.HiddenInput,
-            'assigned_object_id': forms.HiddenInput,
-        }
-
-
-class JournalEntryBulkEditForm(BootstrapMixin, BulkEditForm):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=JournalEntry.objects.all(),
-        widget=forms.MultipleHiddenInput
-    )
-    kind = forms.ChoiceField(
-        choices=JournalEntryKindChoices,
-        required=False
-    )
-    comments = forms.CharField(
-        required=False,
-        widget=forms.Textarea()
-    )
-
-    class Meta:
-        nullable_fields = []
-
-
-class JournalEntryFilterForm(BootstrapMixin, forms.Form):
-    model = JournalEntry
-    field_groups = [
-        ['q'],
-        ['created_before', 'created_after', 'created_by_id'],
-        ['assigned_object_type_id', 'kind']
-    ]
-    q = forms.CharField(
-        required=False,
-        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
-        label=_('Search')
-    )
-    created_after = forms.DateTimeField(
-        required=False,
-        label=_('After'),
-        widget=DateTimePicker()
-    )
-    created_before = forms.DateTimeField(
-        required=False,
-        label=_('Before'),
-        widget=DateTimePicker()
-    )
-    created_by_id = DynamicModelMultipleChoiceField(
-        queryset=User.objects.all(),
-        required=False,
-        label=_('User'),
-        widget=APISelectMultiple(
-            api_url='/api/users/users/',
-        ),
-        fetch_trigger='open'
-    )
-    assigned_object_type_id = DynamicModelMultipleChoiceField(
-        queryset=ContentType.objects.all(),
-        required=False,
-        label=_('Object Type'),
-        widget=APISelectMultiple(
-            api_url='/api/extras/content-types/',
-        ),
-        fetch_trigger='open'
-    )
-    kind = forms.ChoiceField(
-        choices=add_blank_choice(JournalEntryKindChoices),
-        required=False,
-        widget=StaticSelect()
-    )
-
-
-#
-# Change logging
-#
-
-class ObjectChangeFilterForm(BootstrapMixin, forms.Form):
-    model = ObjectChange
-    field_groups = [
-        ['q'],
-        ['time_before', 'time_after', 'action'],
-        ['user_id', 'changed_object_type_id'],
-    ]
-    q = forms.CharField(
-        required=False,
-        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
-        label=_('Search')
-    )
-    time_after = forms.DateTimeField(
-        required=False,
-        label=_('After'),
-        widget=DateTimePicker()
-    )
-    time_before = forms.DateTimeField(
-        required=False,
-        label=_('Before'),
-        widget=DateTimePicker()
-    )
-    action = forms.ChoiceField(
-        choices=add_blank_choice(ObjectChangeActionChoices),
-        required=False,
-        widget=StaticSelect()
-    )
-    user_id = DynamicModelMultipleChoiceField(
-        queryset=User.objects.all(),
-        required=False,
-        label=_('User'),
-        widget=APISelectMultiple(
-            api_url='/api/users/users/',
-        ),
-        fetch_trigger='open'
-    )
-    changed_object_type_id = DynamicModelMultipleChoiceField(
-        queryset=ContentType.objects.all(),
-        required=False,
-        label=_('Object Type'),
-        widget=APISelectMultiple(
-            api_url='/api/extras/content-types/',
-        ),
-        fetch_trigger='open'
-    )
-
-
-#
-# Scripts
-#
-
-class ScriptForm(BootstrapMixin, forms.Form):
-    _commit = forms.BooleanField(
-        required=False,
-        initial=True,
-        label="Commit changes",
-        help_text="Commit changes to the database (uncheck for a dry-run)"
-    )
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-
-        # Move _commit to the end of the form
-        commit = self.fields.pop('_commit')
-        self.fields['_commit'] = commit
-
-    @property
-    def requires_input(self):
-        """
-        A boolean indicating whether the form requires user input (ignore the _commit field).
-        """
-        return bool(len(self.fields) > 1)

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

@@ -0,0 +1,6 @@
+from .models import *
+from .filtersets import *
+from .bulk_edit import *
+from .bulk_import import *
+from .customfields import *
+from .scripts import *

+ 199 - 0
netbox/extras/forms/bulk_edit.py

@@ -0,0 +1,199 @@
+from django import forms
+from django.contrib.contenttypes.models import ContentType
+
+from extras.choices import *
+from extras.models import *
+from extras.utils import FeatureQuery
+from utilities.forms import (
+    BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorField, ContentTypeChoiceField, StaticSelect,
+)
+
+__all__ = (
+    'ConfigContextBulkEditForm',
+    'CustomFieldBulkEditForm',
+    'CustomLinkBulkEditForm',
+    'ExportTemplateBulkEditForm',
+    'JournalEntryBulkEditForm',
+    'TagBulkEditForm',
+    'WebhookBulkEditForm',
+)
+
+
+class CustomFieldBulkEditForm(BootstrapMixin, BulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=CustomField.objects.all(),
+        widget=forms.MultipleHiddenInput
+    )
+    description = forms.CharField(
+        required=False
+    )
+    required = forms.NullBooleanField(
+        required=False,
+        widget=BulkEditNullBooleanSelect()
+    )
+    weight = forms.IntegerField(
+        required=False
+    )
+
+    class Meta:
+        nullable_fields = []
+
+
+class CustomLinkBulkEditForm(BootstrapMixin, BulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=CustomLink.objects.all(),
+        widget=forms.MultipleHiddenInput
+    )
+    content_type = ContentTypeChoiceField(
+        queryset=ContentType.objects.all(),
+        limit_choices_to=FeatureQuery('custom_fields'),
+        required=False
+    )
+    new_window = forms.NullBooleanField(
+        required=False,
+        widget=BulkEditNullBooleanSelect()
+    )
+    weight = forms.IntegerField(
+        required=False
+    )
+    button_class = forms.ChoiceField(
+        choices=CustomLinkButtonClassChoices,
+        required=False,
+        widget=StaticSelect()
+    )
+
+    class Meta:
+        nullable_fields = []
+
+
+class ExportTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=ExportTemplate.objects.all(),
+        widget=forms.MultipleHiddenInput
+    )
+    content_type = ContentTypeChoiceField(
+        queryset=ContentType.objects.all(),
+        limit_choices_to=FeatureQuery('custom_fields'),
+        required=False
+    )
+    description = forms.CharField(
+        max_length=200,
+        required=False
+    )
+    mime_type = forms.CharField(
+        max_length=50,
+        required=False
+    )
+    file_extension = forms.CharField(
+        max_length=15,
+        required=False
+    )
+    as_attachment = forms.NullBooleanField(
+        required=False,
+        widget=BulkEditNullBooleanSelect()
+    )
+
+    class Meta:
+        nullable_fields = ['description', 'mime_type', 'file_extension']
+
+
+class WebhookBulkEditForm(BootstrapMixin, BulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=Webhook.objects.all(),
+        widget=forms.MultipleHiddenInput
+    )
+    enabled = forms.NullBooleanField(
+        required=False,
+        widget=BulkEditNullBooleanSelect()
+    )
+    type_create = forms.NullBooleanField(
+        required=False,
+        widget=BulkEditNullBooleanSelect()
+    )
+    type_update = forms.NullBooleanField(
+        required=False,
+        widget=BulkEditNullBooleanSelect()
+    )
+    type_delete = forms.NullBooleanField(
+        required=False,
+        widget=BulkEditNullBooleanSelect()
+    )
+    http_method = forms.ChoiceField(
+        choices=WebhookHttpMethodChoices,
+        required=False
+    )
+    payload_url = forms.CharField(
+        required=False
+    )
+    ssl_verification = forms.NullBooleanField(
+        required=False,
+        widget=BulkEditNullBooleanSelect()
+    )
+    secret = forms.CharField(
+        required=False
+    )
+    ca_file_path = forms.CharField(
+        required=False
+    )
+
+    class Meta:
+        nullable_fields = ['secret', 'ca_file_path']
+
+
+class TagBulkEditForm(BootstrapMixin, BulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
+        widget=forms.MultipleHiddenInput
+    )
+    color = ColorField(
+        required=False
+    )
+    description = forms.CharField(
+        max_length=200,
+        required=False
+    )
+
+    class Meta:
+        nullable_fields = ['description']
+
+
+class ConfigContextBulkEditForm(BootstrapMixin, BulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=ConfigContext.objects.all(),
+        widget=forms.MultipleHiddenInput
+    )
+    weight = forms.IntegerField(
+        required=False,
+        min_value=0
+    )
+    is_active = forms.NullBooleanField(
+        required=False,
+        widget=BulkEditNullBooleanSelect()
+    )
+    description = forms.CharField(
+        required=False,
+        max_length=100
+    )
+
+    class Meta:
+        nullable_fields = [
+            'description',
+        ]
+
+
+class JournalEntryBulkEditForm(BootstrapMixin, BulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=JournalEntry.objects.all(),
+        widget=forms.MultipleHiddenInput
+    )
+    kind = forms.ChoiceField(
+        choices=JournalEntryKindChoices,
+        required=False
+    )
+    comments = forms.CharField(
+        required=False,
+        widget=forms.Textarea()
+    )
+
+    class Meta:
+        nullable_fields = []

+ 91 - 0
netbox/extras/forms/bulk_import.py

@@ -0,0 +1,91 @@
+from django import forms
+from django.contrib.contenttypes.models import ContentType
+from django.contrib.postgres.forms import SimpleArrayField
+from django.utils.safestring import mark_safe
+
+from extras.models import *
+from extras.utils import FeatureQuery
+from utilities.forms import CSVContentTypeField, CSVModelForm, CSVMultipleContentTypeField, SlugField
+
+__all__ = (
+    'CustomFieldCSVForm',
+    'CustomLinkCSVForm',
+    'ExportTemplateCSVForm',
+    'TagCSVForm',
+    'WebhookCSVForm',
+)
+
+
+class CustomFieldCSVForm(CSVModelForm):
+    content_types = CSVMultipleContentTypeField(
+        queryset=ContentType.objects.all(),
+        limit_choices_to=FeatureQuery('custom_fields'),
+        help_text="One or more assigned object types"
+    )
+    choices = SimpleArrayField(
+        base_field=forms.CharField(),
+        required=False,
+        help_text='Comma-separated list of field choices'
+    )
+
+    class Meta:
+        model = CustomField
+        fields = (
+            'name', 'label', 'type', 'content_types', 'required', 'description', 'weight', 'filter_logic', 'default',
+            'choices', 'weight',
+        )
+
+
+class CustomLinkCSVForm(CSVModelForm):
+    content_type = CSVContentTypeField(
+        queryset=ContentType.objects.all(),
+        limit_choices_to=FeatureQuery('custom_links'),
+        help_text="Assigned object type"
+    )
+
+    class Meta:
+        model = CustomLink
+        fields = (
+            'name', 'content_type', 'weight', 'group_name', 'button_class', 'new_window', 'link_text', 'link_url',
+        )
+
+
+class ExportTemplateCSVForm(CSVModelForm):
+    content_type = CSVContentTypeField(
+        queryset=ContentType.objects.all(),
+        limit_choices_to=FeatureQuery('export_templates'),
+        help_text="Assigned object type"
+    )
+
+    class Meta:
+        model = ExportTemplate
+        fields = (
+            'name', 'content_type', 'description', 'mime_type', 'file_extension', 'as_attachment', 'template_code',
+        )
+
+
+class WebhookCSVForm(CSVModelForm):
+    content_types = CSVMultipleContentTypeField(
+        queryset=ContentType.objects.all(),
+        limit_choices_to=FeatureQuery('webhooks'),
+        help_text="One or more assigned object types"
+    )
+
+    class Meta:
+        model = Webhook
+        fields = (
+            'name', 'enabled', 'content_types', 'type_create', 'type_update', 'type_delete', 'payload_url',
+            'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret', 'ssl_verification',
+            'ca_file_path'
+        )
+
+
+class TagCSVForm(CSVModelForm):
+    slug = SlugField()
+
+    class Meta:
+        model = Tag
+        fields = ('name', 'slug', 'color', 'description')
+        help_texts = {
+            'color': mark_safe('RGB color in hexadecimal (e.g. <code>00ff00</code>)'),
+        }

+ 123 - 0
netbox/extras/forms/customfields.py

@@ -0,0 +1,123 @@
+from django import forms
+from django.contrib.contenttypes.models import ContentType
+
+from extras.choices import *
+from extras.models import *
+from utilities.forms import BulkEditForm, CSVModelForm
+
+__all__ = (
+    'CustomFieldModelCSVForm',
+    'CustomFieldModelBulkEditForm',
+    'CustomFieldModelFilterForm',
+    'CustomFieldModelForm',
+    'CustomFieldsMixin',
+)
+
+
+class CustomFieldsMixin:
+    """
+    Extend a Form to include custom field support.
+    """
+    def __init__(self, *args, **kwargs):
+        self.custom_fields = []
+
+        super().__init__(*args, **kwargs)
+
+        self._append_customfield_fields()
+
+    def _get_content_type(self):
+        """
+        Return the ContentType of the form's model.
+        """
+        if not hasattr(self, 'model'):
+            raise NotImplementedError(f"{self.__class__.__name__} must specify a model class.")
+        return ContentType.objects.get_for_model(self.model)
+
+    def _get_form_field(self, customfield):
+        return customfield.to_form_field()
+
+    def _append_customfield_fields(self):
+        """
+        Append form fields for all CustomFields assigned to this object type.
+        """
+        content_type = self._get_content_type()
+
+        # Append form fields; assign initial values if modifying and existing object
+        for customfield in CustomField.objects.filter(content_types=content_type):
+            field_name = f'cf_{customfield.name}'
+            self.fields[field_name] = self._get_form_field(customfield)
+
+            # Annotate the field in the list of CustomField form fields
+            self.custom_fields.append(field_name)
+
+
+class CustomFieldModelForm(CustomFieldsMixin, forms.ModelForm):
+    """
+    Extend ModelForm to include custom field support.
+    """
+    def _get_content_type(self):
+        return ContentType.objects.get_for_model(self._meta.model)
+
+    def _get_form_field(self, customfield):
+        if self.instance.pk:
+            form_field = customfield.to_form_field(set_initial=False)
+            form_field.initial = self.instance.custom_field_data.get(customfield.name, None)
+            return form_field
+
+        return customfield.to_form_field()
+
+    def clean(self):
+
+        # Save custom field data on instance
+        for cf_name in self.custom_fields:
+            key = cf_name[3:]  # Strip "cf_" from field name
+            value = self.cleaned_data.get(cf_name)
+            empty_values = self.fields[cf_name].empty_values
+            # Convert "empty" values to null
+            self.instance.custom_field_data[key] = value if value not in empty_values else None
+
+        return super().clean()
+
+
+class CustomFieldModelCSVForm(CSVModelForm, CustomFieldModelForm):
+
+    def _get_form_field(self, customfield):
+        return customfield.to_form_field(for_csv_import=True)
+
+
+class CustomFieldModelBulkEditForm(BulkEditForm):
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        self.custom_fields = []
+        self.obj_type = ContentType.objects.get_for_model(self.model)
+
+        # Add all applicable CustomFields to the form
+        custom_fields = CustomField.objects.filter(content_types=self.obj_type)
+        for cf in custom_fields:
+            # Annotate non-required custom fields as nullable
+            if not cf.required:
+                self.nullable_fields.append(cf.name)
+            self.fields[cf.name] = cf.to_form_field(set_initial=False, enforce_required=False)
+            # Annotate this as a custom field
+            self.custom_fields.append(cf.name)
+
+
+class CustomFieldModelFilterForm(forms.Form):
+
+    def __init__(self, *args, **kwargs):
+
+        self.obj_type = ContentType.objects.get_for_model(self.model)
+
+        super().__init__(*args, **kwargs)
+
+        # Add all applicable CustomFields to the form
+        self.custom_field_filters = []
+        custom_fields = CustomField.objects.filter(content_types=self.obj_type).exclude(
+            filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED
+        )
+        for cf in custom_fields:
+            field_name = 'cf_{}'.format(cf.name)
+            self.fields[field_name] = cf.to_form_field(set_initial=True, enforce_required=False)
+            self.custom_field_filters.append(field_name)

+ 364 - 0
netbox/extras/forms/filtersets.py

@@ -0,0 +1,364 @@
+from django import forms
+from django.contrib.auth.models import User
+from django.contrib.contenttypes.models import ContentType
+from django.utils.translation import gettext as _
+
+from dcim.models import DeviceRole, DeviceType, Platform, Region, Site, SiteGroup
+from extras.choices import *
+from extras.models import *
+from extras.utils import FeatureQuery
+from tenancy.models import Tenant, TenantGroup
+from utilities.forms import (
+    add_blank_choice, APISelectMultiple, BootstrapMixin, ContentTypeChoiceField,
+    ContentTypeMultipleChoiceField, DateTimePicker, DynamicModelMultipleChoiceField, StaticSelect,
+    StaticSelectMultiple, BOOLEAN_WITH_BLANK_CHOICES,
+)
+from virtualization.models import Cluster, ClusterGroup
+
+__all__ = (
+    'ConfigContextFilterForm',
+    'CustomFieldFilterForm',
+    'CustomLinkFilterForm',
+    'ExportTemplateFilterForm',
+    'JournalEntryFilterForm',
+    'LocalConfigContextFilterForm',
+    'ObjectChangeFilterForm',
+    'TagFilterForm',
+    'WebhookFilterForm',
+)
+
+
+class CustomFieldFilterForm(BootstrapMixin, forms.Form):
+    field_groups = [
+        ['q'],
+        ['type', 'content_types'],
+        ['weight', 'required'],
+    ]
+    q = forms.CharField(
+        required=False,
+        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
+        label=_('Search')
+    )
+    content_types = ContentTypeMultipleChoiceField(
+        queryset=ContentType.objects.all(),
+        limit_choices_to=FeatureQuery('custom_fields'),
+        required=False
+    )
+    type = forms.MultipleChoiceField(
+        choices=CustomFieldTypeChoices,
+        required=False,
+        widget=StaticSelectMultiple(),
+        label=_('Field type')
+    )
+    weight = forms.IntegerField(
+        required=False
+    )
+    required = forms.NullBooleanField(
+        required=False,
+        widget=StaticSelect(
+            choices=BOOLEAN_WITH_BLANK_CHOICES
+        )
+    )
+
+
+class CustomLinkFilterForm(BootstrapMixin, forms.Form):
+    field_groups = [
+        ['q'],
+        ['content_type', 'weight', 'new_window'],
+    ]
+    q = forms.CharField(
+        required=False,
+        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
+        label=_('Search')
+    )
+    content_type = ContentTypeChoiceField(
+        queryset=ContentType.objects.all(),
+        limit_choices_to=FeatureQuery('custom_fields'),
+        required=False
+    )
+    weight = forms.IntegerField(
+        required=False
+    )
+    new_window = forms.NullBooleanField(
+        required=False,
+        widget=StaticSelect(
+            choices=BOOLEAN_WITH_BLANK_CHOICES
+        )
+    )
+
+
+class ExportTemplateFilterForm(BootstrapMixin, forms.Form):
+    field_groups = [
+        ['q'],
+        ['content_type', 'mime_type', 'file_extension', 'as_attachment'],
+    ]
+    q = forms.CharField(
+        required=False,
+        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
+        label=_('Search')
+    )
+    content_type = ContentTypeChoiceField(
+        queryset=ContentType.objects.all(),
+        limit_choices_to=FeatureQuery('custom_fields'),
+        required=False
+    )
+    mime_type = forms.CharField(
+        required=False,
+        label=_('MIME type')
+    )
+    file_extension = forms.CharField(
+        required=False
+    )
+    as_attachment = forms.NullBooleanField(
+        required=False,
+        widget=StaticSelect(
+            choices=BOOLEAN_WITH_BLANK_CHOICES
+        )
+    )
+
+
+class WebhookFilterForm(BootstrapMixin, forms.Form):
+    field_groups = [
+        ['q'],
+        ['content_types', 'http_method', 'enabled'],
+        ['type_create', 'type_update', 'type_delete'],
+    ]
+    q = forms.CharField(
+        required=False,
+        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
+        label=_('Search')
+    )
+    content_types = ContentTypeMultipleChoiceField(
+        queryset=ContentType.objects.all(),
+        limit_choices_to=FeatureQuery('custom_fields'),
+        required=False
+    )
+    http_method = forms.MultipleChoiceField(
+        choices=WebhookHttpMethodChoices,
+        required=False,
+        widget=StaticSelectMultiple(),
+        label=_('HTTP method')
+    )
+    enabled = forms.NullBooleanField(
+        required=False,
+        widget=StaticSelect(
+            choices=BOOLEAN_WITH_BLANK_CHOICES
+        )
+    )
+    type_create = forms.NullBooleanField(
+        required=False,
+        widget=StaticSelect(
+            choices=BOOLEAN_WITH_BLANK_CHOICES
+        )
+    )
+    type_update = forms.NullBooleanField(
+        required=False,
+        widget=StaticSelect(
+            choices=BOOLEAN_WITH_BLANK_CHOICES
+        )
+    )
+    type_delete = forms.NullBooleanField(
+        required=False,
+        widget=StaticSelect(
+            choices=BOOLEAN_WITH_BLANK_CHOICES
+        )
+    )
+
+
+class TagFilterForm(BootstrapMixin, forms.Form):
+    model = Tag
+    q = forms.CharField(
+        required=False,
+        label=_('Search')
+    )
+    content_type_id = ContentTypeMultipleChoiceField(
+        queryset=ContentType.objects.filter(FeatureQuery('tags').get_query()),
+        required=False,
+        label=_('Tagged object type')
+    )
+
+
+class ConfigContextFilterForm(BootstrapMixin, forms.Form):
+    field_groups = [
+        ['q', 'tag'],
+        ['region_id', 'site_group_id', 'site_id'],
+        ['device_type_id', 'platform_id', 'role_id'],
+        ['cluster_group_id', 'cluster_id'],
+        ['tenant_group_id', 'tenant_id']
+    ]
+    q = forms.CharField(
+        required=False,
+        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
+        label=_('Search')
+    )
+    region_id = DynamicModelMultipleChoiceField(
+        queryset=Region.objects.all(),
+        required=False,
+        label=_('Regions'),
+        fetch_trigger='open'
+    )
+    site_group_id = DynamicModelMultipleChoiceField(
+        queryset=SiteGroup.objects.all(),
+        required=False,
+        label=_('Site groups'),
+        fetch_trigger='open'
+    )
+    site_id = DynamicModelMultipleChoiceField(
+        queryset=Site.objects.all(),
+        required=False,
+        label=_('Sites'),
+        fetch_trigger='open'
+    )
+    device_type_id = DynamicModelMultipleChoiceField(
+        queryset=DeviceType.objects.all(),
+        required=False,
+        label=_('Device types'),
+        fetch_trigger='open'
+    )
+    role_id = DynamicModelMultipleChoiceField(
+        queryset=DeviceRole.objects.all(),
+        required=False,
+        label=_('Roles'),
+        fetch_trigger='open'
+    )
+    platform_id = DynamicModelMultipleChoiceField(
+        queryset=Platform.objects.all(),
+        required=False,
+        label=_('Platforms'),
+        fetch_trigger='open'
+    )
+    cluster_group_id = DynamicModelMultipleChoiceField(
+        queryset=ClusterGroup.objects.all(),
+        required=False,
+        label=_('Cluster groups'),
+        fetch_trigger='open'
+    )
+    cluster_id = DynamicModelMultipleChoiceField(
+        queryset=Cluster.objects.all(),
+        required=False,
+        label=_('Clusters'),
+        fetch_trigger='open'
+    )
+    tenant_group_id = DynamicModelMultipleChoiceField(
+        queryset=TenantGroup.objects.all(),
+        required=False,
+        label=_('Tenant groups'),
+        fetch_trigger='open'
+    )
+    tenant_id = DynamicModelMultipleChoiceField(
+        queryset=Tenant.objects.all(),
+        required=False,
+        label=_('Tenant'),
+        fetch_trigger='open'
+    )
+    tag = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
+        to_field_name='slug',
+        required=False,
+        label=_('Tags'),
+        fetch_trigger='open'
+    )
+
+
+class LocalConfigContextFilterForm(forms.Form):
+    local_context_data = forms.NullBooleanField(
+        required=False,
+        label=_('Has local config context data'),
+        widget=StaticSelect(
+            choices=BOOLEAN_WITH_BLANK_CHOICES
+        )
+    )
+
+
+class JournalEntryFilterForm(BootstrapMixin, forms.Form):
+    model = JournalEntry
+    field_groups = [
+        ['q'],
+        ['created_before', 'created_after', 'created_by_id'],
+        ['assigned_object_type_id', 'kind']
+    ]
+    q = forms.CharField(
+        required=False,
+        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
+        label=_('Search')
+    )
+    created_after = forms.DateTimeField(
+        required=False,
+        label=_('After'),
+        widget=DateTimePicker()
+    )
+    created_before = forms.DateTimeField(
+        required=False,
+        label=_('Before'),
+        widget=DateTimePicker()
+    )
+    created_by_id = DynamicModelMultipleChoiceField(
+        queryset=User.objects.all(),
+        required=False,
+        label=_('User'),
+        widget=APISelectMultiple(
+            api_url='/api/users/users/',
+        ),
+        fetch_trigger='open'
+    )
+    assigned_object_type_id = DynamicModelMultipleChoiceField(
+        queryset=ContentType.objects.all(),
+        required=False,
+        label=_('Object Type'),
+        widget=APISelectMultiple(
+            api_url='/api/extras/content-types/',
+        ),
+        fetch_trigger='open'
+    )
+    kind = forms.ChoiceField(
+        choices=add_blank_choice(JournalEntryKindChoices),
+        required=False,
+        widget=StaticSelect()
+    )
+
+
+class ObjectChangeFilterForm(BootstrapMixin, forms.Form):
+    model = ObjectChange
+    field_groups = [
+        ['q'],
+        ['time_before', 'time_after', 'action'],
+        ['user_id', 'changed_object_type_id'],
+    ]
+    q = forms.CharField(
+        required=False,
+        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
+        label=_('Search')
+    )
+    time_after = forms.DateTimeField(
+        required=False,
+        label=_('After'),
+        widget=DateTimePicker()
+    )
+    time_before = forms.DateTimeField(
+        required=False,
+        label=_('Before'),
+        widget=DateTimePicker()
+    )
+    action = forms.ChoiceField(
+        choices=add_blank_choice(ObjectChangeActionChoices),
+        required=False,
+        widget=StaticSelect()
+    )
+    user_id = DynamicModelMultipleChoiceField(
+        queryset=User.objects.all(),
+        required=False,
+        label=_('User'),
+        widget=APISelectMultiple(
+            api_url='/api/users/users/',
+        ),
+        fetch_trigger='open'
+    )
+    changed_object_type_id = DynamicModelMultipleChoiceField(
+        queryset=ContentType.objects.all(),
+        required=False,
+        label=_('Object Type'),
+        widget=APISelectMultiple(
+            api_url='/api/extras/content-types/',
+        ),
+        fetch_trigger='open'
+    )

+ 223 - 0
netbox/extras/forms/models.py

@@ -0,0 +1,223 @@
+from django import forms
+from django.contrib.contenttypes.models import ContentType
+
+from dcim.models import DeviceRole, DeviceType, Platform, Region, Site, SiteGroup
+from extras.choices import *
+from extras.models import *
+from extras.utils import FeatureQuery
+from tenancy.models import Tenant, TenantGroup
+from utilities.forms import (
+    add_blank_choice, BootstrapMixin, CommentField, ContentTypeChoiceField,
+    ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, JSONField, SlugField, StaticSelect,
+)
+from virtualization.models import Cluster, ClusterGroup
+
+__all__ = (
+    'AddRemoveTagsForm',
+    'ConfigContextForm',
+    'CustomFieldForm',
+    'CustomLinkForm',
+    'ExportTemplateForm',
+    'ImageAttachmentForm',
+    'JournalEntryForm',
+    'TagForm',
+    'WebhookForm',
+)
+
+
+class CustomFieldForm(BootstrapMixin, forms.ModelForm):
+    content_types = ContentTypeMultipleChoiceField(
+        queryset=ContentType.objects.all(),
+        limit_choices_to=FeatureQuery('custom_fields')
+    )
+
+    class Meta:
+        model = CustomField
+        fields = '__all__'
+        fieldsets = (
+            ('Custom Field', ('name', 'label', 'type', 'weight', 'required', 'description')),
+            ('Assigned Models', ('content_types',)),
+            ('Behavior', ('filter_logic',)),
+            ('Values', ('default', 'choices')),
+            ('Validation', ('validation_minimum', 'validation_maximum', 'validation_regex')),
+        )
+
+
+class CustomLinkForm(BootstrapMixin, forms.ModelForm):
+    content_type = ContentTypeChoiceField(
+        queryset=ContentType.objects.all(),
+        limit_choices_to=FeatureQuery('custom_links')
+    )
+
+    class Meta:
+        model = CustomLink
+        fields = '__all__'
+        fieldsets = (
+            ('Custom Link', ('name', 'content_type', 'weight', 'group_name', 'button_class', 'new_window')),
+            ('Templates', ('link_text', 'link_url')),
+        )
+        widgets = {
+            'link_text': forms.Textarea(attrs={'class': 'font-monospace'}),
+            'link_url': forms.Textarea(attrs={'class': 'font-monospace'}),
+        }
+        help_texts = {
+            'link_text': 'Jinja2 template code for the link text. Reference the object as <code>{{ obj }}</code>. '
+                         'Links which render as empty text will not be displayed.',
+            'link_url': 'Jinja2 template code for the link URL. Reference the object as <code>{{ obj }}</code>.',
+        }
+
+
+class ExportTemplateForm(BootstrapMixin, forms.ModelForm):
+    content_type = ContentTypeChoiceField(
+        queryset=ContentType.objects.all(),
+        limit_choices_to=FeatureQuery('custom_links')
+    )
+
+    class Meta:
+        model = ExportTemplate
+        fields = '__all__'
+        fieldsets = (
+            ('Custom Link', ('name', 'content_type', 'description')),
+            ('Template', ('template_code',)),
+            ('Rendering', ('mime_type', 'file_extension', 'as_attachment')),
+        )
+        widgets = {
+            'template_code': forms.Textarea(attrs={'class': 'font-monospace'}),
+        }
+
+
+class WebhookForm(BootstrapMixin, forms.ModelForm):
+    content_types = ContentTypeMultipleChoiceField(
+        queryset=ContentType.objects.all(),
+        limit_choices_to=FeatureQuery('webhooks')
+    )
+
+    class Meta:
+        model = Webhook
+        fields = '__all__'
+        fieldsets = (
+            ('Webhook', ('name', 'enabled')),
+            ('Assigned Models', ('content_types',)),
+            ('Events', ('type_create', 'type_update', 'type_delete')),
+            ('HTTP Request', (
+                'payload_url', 'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret',
+            )),
+            ('SSL', ('ssl_verification', 'ca_file_path')),
+        )
+        widgets = {
+            'additional_headers': forms.Textarea(attrs={'class': 'font-monospace'}),
+            'body_template': forms.Textarea(attrs={'class': 'font-monospace'}),
+        }
+
+
+class TagForm(BootstrapMixin, forms.ModelForm):
+    slug = SlugField()
+
+    class Meta:
+        model = Tag
+        fields = [
+            'name', 'slug', 'color', 'description'
+        ]
+        fieldsets = (
+            ('Tag', ('name', 'slug', 'color', 'description')),
+        )
+
+
+class AddRemoveTagsForm(forms.Form):
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        # Add add/remove tags fields
+        self.fields['add_tags'] = DynamicModelMultipleChoiceField(
+            queryset=Tag.objects.all(),
+            required=False
+        )
+        self.fields['remove_tags'] = DynamicModelMultipleChoiceField(
+            queryset=Tag.objects.all(),
+            required=False
+        )
+
+
+class ConfigContextForm(BootstrapMixin, forms.ModelForm):
+    regions = DynamicModelMultipleChoiceField(
+        queryset=Region.objects.all(),
+        required=False
+    )
+    site_groups = DynamicModelMultipleChoiceField(
+        queryset=SiteGroup.objects.all(),
+        required=False
+    )
+    sites = DynamicModelMultipleChoiceField(
+        queryset=Site.objects.all(),
+        required=False
+    )
+    device_types = DynamicModelMultipleChoiceField(
+        queryset=DeviceType.objects.all(),
+        required=False
+    )
+    roles = DynamicModelMultipleChoiceField(
+        queryset=DeviceRole.objects.all(),
+        required=False
+    )
+    platforms = DynamicModelMultipleChoiceField(
+        queryset=Platform.objects.all(),
+        required=False
+    )
+    cluster_groups = DynamicModelMultipleChoiceField(
+        queryset=ClusterGroup.objects.all(),
+        required=False
+    )
+    clusters = DynamicModelMultipleChoiceField(
+        queryset=Cluster.objects.all(),
+        required=False
+    )
+    tenant_groups = DynamicModelMultipleChoiceField(
+        queryset=TenantGroup.objects.all(),
+        required=False
+    )
+    tenants = DynamicModelMultipleChoiceField(
+        queryset=Tenant.objects.all(),
+        required=False
+    )
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
+        required=False
+    )
+    data = JSONField(
+        label=''
+    )
+
+    class Meta:
+        model = ConfigContext
+        fields = (
+            'name', 'weight', 'description', 'is_active', 'regions', 'site_groups', 'sites', 'roles', 'device_types',
+            'platforms', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', 'data',
+        )
+
+
+class ImageAttachmentForm(BootstrapMixin, forms.ModelForm):
+
+    class Meta:
+        model = ImageAttachment
+        fields = [
+            'name', 'image',
+        ]
+
+
+class JournalEntryForm(BootstrapMixin, forms.ModelForm):
+    comments = CommentField()
+
+    kind = forms.ChoiceField(
+        choices=add_blank_choice(JournalEntryKindChoices),
+        required=False,
+        widget=StaticSelect()
+    )
+
+    class Meta:
+        model = JournalEntry
+        fields = ['assigned_object_type', 'assigned_object_id', 'kind', 'comments']
+        widgets = {
+            'assigned_object_type': forms.HiddenInput,
+            'assigned_object_id': forms.HiddenInput,
+        }

+ 30 - 0
netbox/extras/forms/scripts.py

@@ -0,0 +1,30 @@
+from django import forms
+
+from utilities.forms import BootstrapMixin
+
+__all__ = (
+    'ScriptForm',
+)
+
+
+class ScriptForm(BootstrapMixin, forms.Form):
+    _commit = forms.BooleanField(
+        required=False,
+        initial=True,
+        label="Commit changes",
+        help_text="Commit changes to the database (uncheck for a dry-run)"
+    )
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        # Move _commit to the end of the form
+        commit = self.fields.pop('_commit')
+        self.fields['_commit'] = commit
+
+    @property
+    def requires_input(self):
+        """
+        A boolean indicating whether the form requires user input (ignore the _commit field).
+        """
+        return bool(len(self.fields) > 1)

+ 0 - 1881
netbox/ipam/forms.py

@@ -1,1881 +0,0 @@
-from django import forms
-from django.contrib.contenttypes.models import ContentType
-from django.utils.translation import gettext as _
-
-from dcim.models import Device, Interface, Location, Rack, Region, Site, SiteGroup
-from extras.forms import (
-    AddRemoveTagsForm, CustomFieldModelBulkEditForm, CustomFieldModelCSVForm, CustomFieldModelForm,
-    CustomFieldModelFilterForm,
-)
-from extras.models import Tag
-from tenancy.forms import TenancyFilterForm, TenancyForm
-from tenancy.models import Tenant
-from utilities.forms import (
-    add_blank_choice, BootstrapMixin, BulkEditNullBooleanSelect, ContentTypeChoiceField, CSVChoiceField,
-    CSVContentTypeField, CSVModelChoiceField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
-    ExpandableIPAddressField, NumericArrayField, SlugField, StaticSelect, StaticSelectMultiple, TagFilterField,
-    BOOLEAN_WITH_BLANK_CHOICES,
-)
-from virtualization.models import Cluster, ClusterGroup, VirtualMachine, VMInterface
-from .choices import *
-from .constants import *
-from .models import *
-
-PREFIX_MASK_LENGTH_CHOICES = add_blank_choice([
-    (i, i) for i in range(PREFIX_LENGTH_MIN, PREFIX_LENGTH_MAX + 1)
-])
-
-IPADDRESS_MASK_LENGTH_CHOICES = add_blank_choice([
-    (i, i) for i in range(IPADDRESS_MASK_LENGTH_MIN, IPADDRESS_MASK_LENGTH_MAX + 1)
-])
-
-
-#
-# VRFs
-#
-
-class VRFForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
-    import_targets = DynamicModelMultipleChoiceField(
-        queryset=RouteTarget.objects.all(),
-        required=False
-    )
-    export_targets = DynamicModelMultipleChoiceField(
-        queryset=RouteTarget.objects.all(),
-        required=False
-    )
-    tags = DynamicModelMultipleChoiceField(
-        queryset=Tag.objects.all(),
-        required=False
-    )
-
-    class Meta:
-        model = VRF
-        fields = [
-            'name', 'rd', 'enforce_unique', 'description', 'import_targets', 'export_targets', 'tenant_group', 'tenant',
-            'tags',
-        ]
-        fieldsets = (
-            ('VRF', ('name', 'rd', 'enforce_unique', 'description', 'tags')),
-            ('Route Targets', ('import_targets', 'export_targets')),
-            ('Tenancy', ('tenant_group', 'tenant')),
-        )
-        labels = {
-            'rd': "RD",
-        }
-        help_texts = {
-            'rd': "Route distinguisher in any format",
-        }
-
-
-class VRFCSVForm(CustomFieldModelCSVForm):
-    tenant = CSVModelChoiceField(
-        queryset=Tenant.objects.all(),
-        required=False,
-        to_field_name='name',
-        help_text='Assigned tenant'
-    )
-
-    class Meta:
-        model = VRF
-        fields = ('name', 'rd', 'tenant', 'enforce_unique', 'description')
-
-
-class VRFBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=VRF.objects.all(),
-        widget=forms.MultipleHiddenInput()
-    )
-    tenant = DynamicModelChoiceField(
-        queryset=Tenant.objects.all(),
-        required=False
-    )
-    enforce_unique = forms.NullBooleanField(
-        required=False,
-        widget=BulkEditNullBooleanSelect(),
-        label='Enforce unique space'
-    )
-    description = forms.CharField(
-        max_length=100,
-        required=False
-    )
-
-    class Meta:
-        nullable_fields = [
-            'tenant', 'description',
-        ]
-
-
-class VRFFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
-    model = VRF
-    field_groups = [
-        ['q', 'tag'],
-        ['import_target_id', 'export_target_id'],
-        ['tenant_group_id', 'tenant_id'],
-    ]
-    q = forms.CharField(
-        required=False,
-        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
-        label=_('Search')
-    )
-    import_target_id = DynamicModelMultipleChoiceField(
-        queryset=RouteTarget.objects.all(),
-        required=False,
-        label=_('Import targets'),
-        fetch_trigger='open'
-    )
-    export_target_id = DynamicModelMultipleChoiceField(
-        queryset=RouteTarget.objects.all(),
-        required=False,
-        label=_('Export targets'),
-        fetch_trigger='open'
-    )
-    tag = TagFilterField(model)
-
-
-#
-# Route targets
-#
-
-class RouteTargetForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
-    tags = DynamicModelMultipleChoiceField(
-        queryset=Tag.objects.all(),
-        required=False
-    )
-
-    class Meta:
-        model = RouteTarget
-        fields = [
-            'name', 'description', 'tenant_group', 'tenant', 'tags',
-        ]
-        fieldsets = (
-            ('Route Target', ('name', 'description', 'tags')),
-            ('Tenancy', ('tenant_group', 'tenant')),
-        )
-
-
-class RouteTargetCSVForm(CustomFieldModelCSVForm):
-    tenant = CSVModelChoiceField(
-        queryset=Tenant.objects.all(),
-        required=False,
-        to_field_name='name',
-        help_text='Assigned tenant'
-    )
-
-    class Meta:
-        model = RouteTarget
-        fields = ('name', 'description', 'tenant')
-
-
-class RouteTargetBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=RouteTarget.objects.all(),
-        widget=forms.MultipleHiddenInput()
-    )
-    tenant = DynamicModelChoiceField(
-        queryset=Tenant.objects.all(),
-        required=False
-    )
-    description = forms.CharField(
-        max_length=200,
-        required=False
-    )
-
-    class Meta:
-        nullable_fields = [
-            'tenant', 'description',
-        ]
-
-
-class RouteTargetFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
-    model = RouteTarget
-    field_groups = [
-        ['q', 'tag'],
-        ['importing_vrf_id', 'exporting_vrf_id'],
-        ['tenant_group_id', 'tenant_id'],
-    ]
-    q = forms.CharField(
-        required=False,
-        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
-        label=_('Search')
-    )
-    importing_vrf_id = DynamicModelMultipleChoiceField(
-        queryset=VRF.objects.all(),
-        required=False,
-        label=_('Imported by VRF'),
-        fetch_trigger='open'
-    )
-    exporting_vrf_id = DynamicModelMultipleChoiceField(
-        queryset=VRF.objects.all(),
-        required=False,
-        label=_('Exported by VRF'),
-        fetch_trigger='open'
-    )
-    tag = TagFilterField(model)
-
-
-#
-# RIRs
-#
-
-class RIRForm(BootstrapMixin, CustomFieldModelForm):
-    slug = SlugField()
-
-    class Meta:
-        model = RIR
-        fields = [
-            'name', 'slug', 'is_private', 'description',
-        ]
-
-
-class RIRCSVForm(CustomFieldModelCSVForm):
-    slug = SlugField()
-
-    class Meta:
-        model = RIR
-        fields = ('name', 'slug', 'is_private', 'description')
-        help_texts = {
-            'name': 'RIR name',
-        }
-
-
-class RIRBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=RIR.objects.all(),
-        widget=forms.MultipleHiddenInput
-    )
-    is_private = forms.NullBooleanField(
-        required=False,
-        widget=BulkEditNullBooleanSelect
-    )
-    description = forms.CharField(
-        max_length=200,
-        required=False
-    )
-
-    class Meta:
-        nullable_fields = ['is_private', 'description']
-
-
-class RIRFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
-    model = RIR
-    field_groups = [
-        ['q'],
-        ['is_private'],
-    ]
-    q = forms.CharField(
-        required=False,
-        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
-        label=_('Search')
-    )
-    is_private = forms.NullBooleanField(
-        required=False,
-        label=_('Private'),
-        widget=StaticSelect(
-            choices=BOOLEAN_WITH_BLANK_CHOICES
-        )
-    )
-
-
-#
-# Aggregates
-#
-
-class AggregateForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
-    rir = DynamicModelChoiceField(
-        queryset=RIR.objects.all(),
-        label='RIR'
-    )
-    tags = DynamicModelMultipleChoiceField(
-        queryset=Tag.objects.all(),
-        required=False
-    )
-
-    class Meta:
-        model = Aggregate
-        fields = [
-            'prefix', 'rir', 'date_added', 'description', 'tenant_group', 'tenant', 'tags',
-        ]
-        fieldsets = (
-            ('Aggregate', ('prefix', 'rir', 'date_added', 'description', 'tags')),
-            ('Tenancy', ('tenant_group', 'tenant')),
-        )
-        help_texts = {
-            'prefix': "IPv4 or IPv6 network",
-            'rir': "Regional Internet Registry responsible for this prefix",
-        }
-        widgets = {
-            'date_added': DatePicker(),
-        }
-
-
-class AggregateCSVForm(CustomFieldModelCSVForm):
-    rir = CSVModelChoiceField(
-        queryset=RIR.objects.all(),
-        to_field_name='name',
-        help_text='Assigned RIR'
-    )
-    tenant = CSVModelChoiceField(
-        queryset=Tenant.objects.all(),
-        required=False,
-        to_field_name='name',
-        help_text='Assigned tenant'
-    )
-
-    class Meta:
-        model = Aggregate
-        fields = ('prefix', 'rir', 'tenant', 'date_added', 'description')
-
-
-class AggregateBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=Aggregate.objects.all(),
-        widget=forms.MultipleHiddenInput()
-    )
-    rir = DynamicModelChoiceField(
-        queryset=RIR.objects.all(),
-        required=False,
-        label='RIR'
-    )
-    tenant = DynamicModelChoiceField(
-        queryset=Tenant.objects.all(),
-        required=False
-    )
-    date_added = forms.DateField(
-        required=False
-    )
-    description = forms.CharField(
-        max_length=100,
-        required=False
-    )
-
-    class Meta:
-        nullable_fields = [
-            'date_added', 'description',
-        ]
-        widgets = {
-            'date_added': DatePicker(),
-        }
-
-
-class AggregateFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
-    model = Aggregate
-    field_groups = [
-        ['q', 'tag'],
-        ['family', 'rir_id'],
-        ['tenant_group_id', 'tenant_id']
-    ]
-    q = forms.CharField(
-        required=False,
-        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
-        label=_('Search')
-    )
-    family = forms.ChoiceField(
-        required=False,
-        choices=add_blank_choice(IPAddressFamilyChoices),
-        label=_('Address family'),
-        widget=StaticSelect()
-    )
-    rir_id = DynamicModelMultipleChoiceField(
-        queryset=RIR.objects.all(),
-        required=False,
-        label=_('RIR'),
-        fetch_trigger='open'
-    )
-    tag = TagFilterField(model)
-
-
-#
-# Roles
-#
-
-class RoleForm(BootstrapMixin, CustomFieldModelForm):
-    slug = SlugField()
-
-    class Meta:
-        model = Role
-        fields = [
-            'name', 'slug', 'weight', 'description',
-        ]
-
-
-class RoleCSVForm(CustomFieldModelCSVForm):
-    slug = SlugField()
-
-    class Meta:
-        model = Role
-        fields = ('name', 'slug', 'weight', 'description')
-
-
-class RoleBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=Role.objects.all(),
-        widget=forms.MultipleHiddenInput
-    )
-    weight = forms.IntegerField(
-        required=False
-    )
-    description = forms.CharField(
-        max_length=200,
-        required=False
-    )
-
-    class Meta:
-        nullable_fields = ['description']
-
-
-class RoleFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
-    model = Role
-    field_groups = [
-        ['q'],
-    ]
-    q = forms.CharField(
-        required=False,
-        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
-        label=_('Search')
-    )
-
-
-#
-# Prefixes
-#
-
-class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
-    vrf = DynamicModelChoiceField(
-        queryset=VRF.objects.all(),
-        required=False,
-        label='VRF'
-    )
-    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,
-        null_option='None',
-        query_params={
-            'region_id': '$region',
-            'group_id': '$site_group',
-        }
-    )
-    vlan_group = DynamicModelChoiceField(
-        queryset=VLANGroup.objects.all(),
-        required=False,
-        label='VLAN group',
-        null_option='None',
-        query_params={
-            'site_id': '$site'
-        },
-        initial_params={
-            'vlans': '$vlan'
-        }
-    )
-    vlan = DynamicModelChoiceField(
-        queryset=VLAN.objects.all(),
-        required=False,
-        label='VLAN',
-        query_params={
-            'site_id': '$site',
-            'group_id': '$vlan_group',
-        }
-    )
-    role = DynamicModelChoiceField(
-        queryset=Role.objects.all(),
-        required=False
-    )
-    tags = DynamicModelMultipleChoiceField(
-        queryset=Tag.objects.all(),
-        required=False
-    )
-
-    class Meta:
-        model = Prefix
-        fields = [
-            'prefix', 'vrf', 'site', 'vlan', 'status', 'role', 'is_pool', 'mark_utilized', 'description',
-            'tenant_group', 'tenant', 'tags',
-        ]
-        fieldsets = (
-            ('Prefix', ('prefix', 'status', 'vrf', 'role', 'is_pool', 'mark_utilized', 'description', 'tags')),
-            ('Site/VLAN Assignment', ('region', 'site_group', 'site', 'vlan_group', 'vlan')),
-            ('Tenancy', ('tenant_group', 'tenant')),
-        )
-        widgets = {
-            'status': StaticSelect(),
-        }
-
-
-class PrefixCSVForm(CustomFieldModelCSVForm):
-    vrf = CSVModelChoiceField(
-        queryset=VRF.objects.all(),
-        to_field_name='name',
-        required=False,
-        help_text='Assigned VRF'
-    )
-    tenant = CSVModelChoiceField(
-        queryset=Tenant.objects.all(),
-        required=False,
-        to_field_name='name',
-        help_text='Assigned tenant'
-    )
-    site = CSVModelChoiceField(
-        queryset=Site.objects.all(),
-        required=False,
-        to_field_name='name',
-        help_text='Assigned site'
-    )
-    vlan_group = CSVModelChoiceField(
-        queryset=VLANGroup.objects.all(),
-        required=False,
-        to_field_name='name',
-        help_text="VLAN's group (if any)"
-    )
-    vlan = CSVModelChoiceField(
-        queryset=VLAN.objects.all(),
-        required=False,
-        to_field_name='vid',
-        help_text="Assigned VLAN"
-    )
-    status = CSVChoiceField(
-        choices=PrefixStatusChoices,
-        help_text='Operational status'
-    )
-    role = CSVModelChoiceField(
-        queryset=Role.objects.all(),
-        required=False,
-        to_field_name='name',
-        help_text='Functional role'
-    )
-
-    class Meta:
-        model = Prefix
-        fields = (
-            'prefix', 'vrf', 'tenant', 'site', 'vlan_group', 'vlan', 'status', 'role', 'is_pool', 'mark_utilized',
-            'description',
-        )
-
-    def __init__(self, data=None, *args, **kwargs):
-        super().__init__(data, *args, **kwargs)
-
-        if data:
-
-            # Limit VLAN queryset by assigned site and/or group (if specified)
-            params = {}
-            if data.get('site'):
-                params[f"site__{self.fields['site'].to_field_name}"] = data.get('site')
-            if data.get('vlan_group'):
-                params[f"group__{self.fields['vlan_group'].to_field_name}"] = data.get('vlan_group')
-            if params:
-                self.fields['vlan'].queryset = self.fields['vlan'].queryset.filter(**params)
-
-
-class PrefixBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=Prefix.objects.all(),
-        widget=forms.MultipleHiddenInput()
-    )
-    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',
-        }
-    )
-    vrf = DynamicModelChoiceField(
-        queryset=VRF.objects.all(),
-        required=False,
-        label='VRF'
-    )
-    prefix_length = forms.IntegerField(
-        min_value=PREFIX_LENGTH_MIN,
-        max_value=PREFIX_LENGTH_MAX,
-        required=False
-    )
-    tenant = DynamicModelChoiceField(
-        queryset=Tenant.objects.all(),
-        required=False
-    )
-    status = forms.ChoiceField(
-        choices=add_blank_choice(PrefixStatusChoices),
-        required=False,
-        widget=StaticSelect()
-    )
-    role = DynamicModelChoiceField(
-        queryset=Role.objects.all(),
-        required=False
-    )
-    is_pool = forms.NullBooleanField(
-        required=False,
-        widget=BulkEditNullBooleanSelect(),
-        label='Is a pool'
-    )
-    mark_utilized = forms.NullBooleanField(
-        required=False,
-        widget=BulkEditNullBooleanSelect(),
-        label='Treat as 100% utilized'
-    )
-    description = forms.CharField(
-        max_length=100,
-        required=False
-    )
-
-    class Meta:
-        nullable_fields = [
-            'site', 'vrf', 'tenant', 'role', 'description',
-        ]
-
-
-class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
-    model = Prefix
-    field_groups = [
-        ['q', 'tag'],
-        ['within_include', 'family', 'status', 'role_id', 'mask_length', 'is_pool', 'mark_utilized'],
-        ['vrf_id', 'present_in_vrf_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')
-    )
-    mask_length__lte = forms.IntegerField(
-        widget=forms.HiddenInput()
-    )
-    within_include = forms.CharField(
-        required=False,
-        widget=forms.TextInput(
-            attrs={
-                'placeholder': 'Prefix',
-            }
-        ),
-        label=_('Search within')
-    )
-    family = forms.ChoiceField(
-        required=False,
-        choices=add_blank_choice(IPAddressFamilyChoices),
-        label=_('Address family'),
-        widget=StaticSelect()
-    )
-    mask_length = forms.MultipleChoiceField(
-        required=False,
-        choices=PREFIX_MASK_LENGTH_CHOICES,
-        label=_('Mask length'),
-        widget=StaticSelectMultiple()
-    )
-    vrf_id = DynamicModelMultipleChoiceField(
-        queryset=VRF.objects.all(),
-        required=False,
-        label=_('Assigned VRF'),
-        null_option='Global',
-        fetch_trigger='open'
-    )
-    present_in_vrf_id = DynamicModelChoiceField(
-        queryset=VRF.objects.all(),
-        required=False,
-        label=_('Present in VRF'),
-        fetch_trigger='open'
-    )
-    status = forms.MultipleChoiceField(
-        choices=PrefixStatusChoices,
-        required=False,
-        widget=StaticSelectMultiple()
-    )
-    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'
-        },
-        label=_('Site'),
-        fetch_trigger='open'
-    )
-    role_id = DynamicModelMultipleChoiceField(
-        queryset=Role.objects.all(),
-        required=False,
-        null_option='None',
-        label=_('Role'),
-        fetch_trigger='open'
-    )
-    is_pool = forms.NullBooleanField(
-        required=False,
-        label=_('Is a pool'),
-        widget=StaticSelect(
-            choices=BOOLEAN_WITH_BLANK_CHOICES
-        )
-    )
-    mark_utilized = forms.NullBooleanField(
-        required=False,
-        label=_('Marked as 100% utilized'),
-        widget=StaticSelect(
-            choices=BOOLEAN_WITH_BLANK_CHOICES
-        )
-    )
-    tag = TagFilterField(model)
-
-
-#
-# IP ranges
-#
-
-class IPRangeForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
-    vrf = DynamicModelChoiceField(
-        queryset=VRF.objects.all(),
-        required=False,
-        label='VRF'
-    )
-    role = DynamicModelChoiceField(
-        queryset=Role.objects.all(),
-        required=False
-    )
-    tags = DynamicModelMultipleChoiceField(
-        queryset=Tag.objects.all(),
-        required=False
-    )
-
-    class Meta:
-        model = IPRange
-        fields = [
-            'vrf', 'start_address', 'end_address', 'status', 'role', 'description', 'tenant_group', 'tenant', 'tags',
-        ]
-        fieldsets = (
-            ('IP Range', ('vrf', 'start_address', 'end_address', 'role', 'status', 'description', 'tags')),
-            ('Tenancy', ('tenant_group', 'tenant')),
-        )
-        widgets = {
-            'status': StaticSelect(),
-        }
-
-
-class IPRangeCSVForm(CustomFieldModelCSVForm):
-    vrf = CSVModelChoiceField(
-        queryset=VRF.objects.all(),
-        to_field_name='name',
-        required=False,
-        help_text='Assigned VRF'
-    )
-    tenant = CSVModelChoiceField(
-        queryset=Tenant.objects.all(),
-        required=False,
-        to_field_name='name',
-        help_text='Assigned tenant'
-    )
-    status = CSVChoiceField(
-        choices=IPRangeStatusChoices,
-        help_text='Operational status'
-    )
-    role = CSVModelChoiceField(
-        queryset=Role.objects.all(),
-        required=False,
-        to_field_name='name',
-        help_text='Functional role'
-    )
-
-    class Meta:
-        model = IPRange
-        fields = (
-            'start_address', 'end_address', 'vrf', 'tenant', 'status', 'role', 'description',
-        )
-
-
-class IPRangeBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=IPRange.objects.all(),
-        widget=forms.MultipleHiddenInput()
-    )
-    vrf = DynamicModelChoiceField(
-        queryset=VRF.objects.all(),
-        required=False,
-        label='VRF'
-    )
-    tenant = DynamicModelChoiceField(
-        queryset=Tenant.objects.all(),
-        required=False
-    )
-    status = forms.ChoiceField(
-        choices=add_blank_choice(IPRangeStatusChoices),
-        required=False,
-        widget=StaticSelect()
-    )
-    role = DynamicModelChoiceField(
-        queryset=Role.objects.all(),
-        required=False
-    )
-    description = forms.CharField(
-        max_length=100,
-        required=False
-    )
-
-    class Meta:
-        nullable_fields = [
-            'vrf', 'tenant', 'role', 'description',
-        ]
-
-
-class IPRangeFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
-    model = IPRange
-    field_groups = [
-        ['q', 'tag'],
-        ['family', 'vrf_id', 'status', 'role_id'],
-        ['tenant_group_id', 'tenant_id'],
-    ]
-    q = forms.CharField(
-        required=False,
-        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
-        label=_('Search')
-    )
-    family = forms.ChoiceField(
-        required=False,
-        choices=add_blank_choice(IPAddressFamilyChoices),
-        label=_('Address family'),
-        widget=StaticSelect()
-    )
-    vrf_id = DynamicModelMultipleChoiceField(
-        queryset=VRF.objects.all(),
-        required=False,
-        label=_('Assigned VRF'),
-        null_option='Global',
-        fetch_trigger='open'
-    )
-    status = forms.MultipleChoiceField(
-        choices=PrefixStatusChoices,
-        required=False,
-        widget=StaticSelectMultiple()
-    )
-    role_id = DynamicModelMultipleChoiceField(
-        queryset=Role.objects.all(),
-        required=False,
-        null_option='None',
-        label=_('Role'),
-        fetch_trigger='open'
-    )
-    tag = TagFilterField(model)
-
-
-#
-# IP addresses
-#
-
-class IPAddressForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
-    device = DynamicModelChoiceField(
-        queryset=Device.objects.all(),
-        required=False,
-        initial_params={
-            'interfaces': '$interface'
-        }
-    )
-    interface = DynamicModelChoiceField(
-        queryset=Interface.objects.all(),
-        required=False,
-        query_params={
-            'device_id': '$device'
-        }
-    )
-    virtual_machine = DynamicModelChoiceField(
-        queryset=VirtualMachine.objects.all(),
-        required=False,
-        initial_params={
-            'interfaces': '$vminterface'
-        }
-    )
-    vminterface = DynamicModelChoiceField(
-        queryset=VMInterface.objects.all(),
-        required=False,
-        label='Interface',
-        query_params={
-            'virtual_machine_id': '$virtual_machine'
-        }
-    )
-    vrf = DynamicModelChoiceField(
-        queryset=VRF.objects.all(),
-        required=False,
-        label='VRF'
-    )
-    nat_region = DynamicModelChoiceField(
-        queryset=Region.objects.all(),
-        required=False,
-        label='Region',
-        initial_params={
-            'sites': '$nat_site'
-        }
-    )
-    nat_site_group = DynamicModelChoiceField(
-        queryset=SiteGroup.objects.all(),
-        required=False,
-        label='Site group',
-        initial_params={
-            'sites': '$nat_site'
-        }
-    )
-    nat_site = DynamicModelChoiceField(
-        queryset=Site.objects.all(),
-        required=False,
-        label='Site',
-        query_params={
-            'region_id': '$nat_region',
-            'group_id': '$nat_site_group',
-        }
-    )
-    nat_rack = DynamicModelChoiceField(
-        queryset=Rack.objects.all(),
-        required=False,
-        label='Rack',
-        null_option='None',
-        query_params={
-            'site_id': '$site'
-        }
-    )
-    nat_device = DynamicModelChoiceField(
-        queryset=Device.objects.all(),
-        required=False,
-        label='Device',
-        query_params={
-            'site_id': '$site',
-            'rack_id': '$nat_rack',
-        }
-    )
-    nat_cluster = DynamicModelChoiceField(
-        queryset=Cluster.objects.all(),
-        required=False,
-        label='Cluster'
-    )
-    nat_virtual_machine = DynamicModelChoiceField(
-        queryset=VirtualMachine.objects.all(),
-        required=False,
-        label='Virtual Machine',
-        query_params={
-            'cluster_id': '$nat_cluster',
-        }
-    )
-    nat_vrf = DynamicModelChoiceField(
-        queryset=VRF.objects.all(),
-        required=False,
-        label='VRF'
-    )
-    nat_inside = DynamicModelChoiceField(
-        queryset=IPAddress.objects.all(),
-        required=False,
-        label='IP Address',
-        query_params={
-            'device_id': '$nat_device',
-            'virtual_machine_id': '$nat_virtual_machine',
-            'vrf_id': '$nat_vrf',
-        }
-    )
-    primary_for_parent = forms.BooleanField(
-        required=False,
-        label='Make this the primary IP for the device/VM'
-    )
-    tags = DynamicModelMultipleChoiceField(
-        queryset=Tag.objects.all(),
-        required=False
-    )
-
-    class Meta:
-        model = IPAddress
-        fields = [
-            'address', 'vrf', 'status', 'role', 'dns_name', 'description', 'primary_for_parent', 'nat_site', 'nat_rack',
-            'nat_device', 'nat_cluster', 'nat_virtual_machine', 'nat_vrf', 'nat_inside', 'tenant_group', 'tenant',
-            'tags',
-        ]
-        widgets = {
-            'status': StaticSelect(),
-            'role': StaticSelect(),
-        }
-
-    def __init__(self, *args, **kwargs):
-
-        # Initialize helper selectors
-        instance = kwargs.get('instance')
-        initial = kwargs.get('initial', {}).copy()
-        if instance:
-            if type(instance.assigned_object) is Interface:
-                initial['interface'] = instance.assigned_object
-            elif type(instance.assigned_object) is VMInterface:
-                initial['vminterface'] = instance.assigned_object
-            if instance.nat_inside:
-                nat_inside_parent = instance.nat_inside.assigned_object
-                if type(nat_inside_parent) is Interface:
-                    initial['nat_site'] = nat_inside_parent.device.site.pk
-                    if nat_inside_parent.device.rack:
-                        initial['nat_rack'] = nat_inside_parent.device.rack.pk
-                    initial['nat_device'] = nat_inside_parent.device.pk
-                elif type(nat_inside_parent) is VMInterface:
-                    initial['nat_cluster'] = nat_inside_parent.virtual_machine.cluster.pk
-                    initial['nat_virtual_machine'] = nat_inside_parent.virtual_machine.pk
-        kwargs['initial'] = initial
-
-        super().__init__(*args, **kwargs)
-
-        # Initialize primary_for_parent if IP address is already assigned
-        if self.instance.pk and self.instance.assigned_object:
-            parent = self.instance.assigned_object.parent_object
-            if (
-                self.instance.address.version == 4 and parent.primary_ip4_id == self.instance.pk or
-                self.instance.address.version == 6 and parent.primary_ip6_id == self.instance.pk
-            ):
-                self.initial['primary_for_parent'] = True
-
-    def clean(self):
-        super().clean()
-
-        # Cannot select both a device interface and a VM interface
-        if self.cleaned_data.get('interface') and self.cleaned_data.get('vminterface'):
-            raise forms.ValidationError("Cannot select both a device interface and a virtual machine interface")
-        self.instance.assigned_object = self.cleaned_data.get('interface') or self.cleaned_data.get('vminterface')
-
-        # Primary IP assignment is only available if an interface has been assigned.
-        interface = self.cleaned_data.get('interface') or self.cleaned_data.get('vminterface')
-        if self.cleaned_data.get('primary_for_parent') and not interface:
-            self.add_error(
-                'primary_for_parent', "Only IP addresses assigned to an interface can be designated as primary IPs."
-            )
-
-    def save(self, *args, **kwargs):
-        ipaddress = super().save(*args, **kwargs)
-
-        # Assign/clear this IPAddress as the primary for the associated Device/VirtualMachine.
-        interface = self.instance.assigned_object
-        if interface:
-            parent = interface.parent_object
-            if self.cleaned_data['primary_for_parent']:
-                if ipaddress.address.version == 4:
-                    parent.primary_ip4 = ipaddress
-                else:
-                    parent.primary_ip6 = ipaddress
-                parent.save()
-            elif ipaddress.address.version == 4 and parent.primary_ip4 == ipaddress:
-                parent.primary_ip4 = None
-                parent.save()
-            elif ipaddress.address.version == 6 and parent.primary_ip6 == ipaddress:
-                parent.primary_ip6 = None
-                parent.save()
-
-        return ipaddress
-
-
-class IPAddressBulkCreateForm(BootstrapMixin, forms.Form):
-    pattern = ExpandableIPAddressField(
-        label='Address pattern'
-    )
-
-
-class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
-    vrf = DynamicModelChoiceField(
-        queryset=VRF.objects.all(),
-        required=False,
-        label='VRF'
-    )
-    tags = DynamicModelMultipleChoiceField(
-        queryset=Tag.objects.all(),
-        required=False
-    )
-
-    class Meta:
-        model = IPAddress
-        fields = [
-            'address', 'vrf', 'status', 'role', 'dns_name', 'description', 'tenant_group', 'tenant', 'tags',
-        ]
-        widgets = {
-            'status': StaticSelect(),
-            'role': StaticSelect(),
-        }
-
-
-class IPAddressCSVForm(CustomFieldModelCSVForm):
-    vrf = CSVModelChoiceField(
-        queryset=VRF.objects.all(),
-        to_field_name='name',
-        required=False,
-        help_text='Assigned VRF'
-    )
-    tenant = CSVModelChoiceField(
-        queryset=Tenant.objects.all(),
-        to_field_name='name',
-        required=False,
-        help_text='Assigned tenant'
-    )
-    status = CSVChoiceField(
-        choices=IPAddressStatusChoices,
-        required=False,
-        help_text='Operational status'
-    )
-    role = CSVChoiceField(
-        choices=IPAddressRoleChoices,
-        required=False,
-        help_text='Functional role'
-    )
-    device = CSVModelChoiceField(
-        queryset=Device.objects.all(),
-        required=False,
-        to_field_name='name',
-        help_text='Parent device of assigned interface (if any)'
-    )
-    virtual_machine = CSVModelChoiceField(
-        queryset=VirtualMachine.objects.all(),
-        required=False,
-        to_field_name='name',
-        help_text='Parent VM of assigned interface (if any)'
-    )
-    interface = CSVModelChoiceField(
-        queryset=Interface.objects.none(),  # Can also refer to VMInterface
-        required=False,
-        to_field_name='name',
-        help_text='Assigned interface'
-    )
-    is_primary = forms.BooleanField(
-        help_text='Make this the primary IP for the assigned device',
-        required=False
-    )
-
-    class Meta:
-        model = IPAddress
-        fields = [
-            'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface', 'is_primary',
-            'dns_name', 'description',
-        ]
-
-    def __init__(self, data=None, *args, **kwargs):
-        super().__init__(data, *args, **kwargs)
-
-        if data:
-
-            # Limit interface queryset by assigned device
-            if data.get('device'):
-                self.fields['interface'].queryset = Interface.objects.filter(
-                    **{f"device__{self.fields['device'].to_field_name}": data['device']}
-                )
-
-            # Limit interface queryset by assigned device
-            elif data.get('virtual_machine'):
-                self.fields['interface'].queryset = VMInterface.objects.filter(
-                    **{f"virtual_machine__{self.fields['virtual_machine'].to_field_name}": data['virtual_machine']}
-                )
-
-    def clean(self):
-        super().clean()
-
-        device = self.cleaned_data.get('device')
-        virtual_machine = self.cleaned_data.get('virtual_machine')
-        is_primary = self.cleaned_data.get('is_primary')
-
-        # Validate is_primary
-        if is_primary and not device and not virtual_machine:
-            raise forms.ValidationError("No device or virtual machine specified; cannot set as primary IP")
-
-    def save(self, *args, **kwargs):
-
-        # Set interface assignment
-        if self.cleaned_data['interface']:
-            self.instance.assigned_object = self.cleaned_data['interface']
-
-        ipaddress = super().save(*args, **kwargs)
-
-        # Set as primary for device/VM
-        if self.cleaned_data['is_primary']:
-            parent = self.cleaned_data['device'] or self.cleaned_data['virtual_machine']
-            if self.instance.address.version == 4:
-                parent.primary_ip4 = ipaddress
-            elif self.instance.address.version == 6:
-                parent.primary_ip6 = ipaddress
-            parent.save()
-
-        return ipaddress
-
-
-class IPAddressBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=IPAddress.objects.all(),
-        widget=forms.MultipleHiddenInput()
-    )
-    vrf = DynamicModelChoiceField(
-        queryset=VRF.objects.all(),
-        required=False,
-        label='VRF'
-    )
-    mask_length = forms.IntegerField(
-        min_value=IPADDRESS_MASK_LENGTH_MIN,
-        max_value=IPADDRESS_MASK_LENGTH_MAX,
-        required=False
-    )
-    tenant = DynamicModelChoiceField(
-        queryset=Tenant.objects.all(),
-        required=False
-    )
-    status = forms.ChoiceField(
-        choices=add_blank_choice(IPAddressStatusChoices),
-        required=False,
-        widget=StaticSelect()
-    )
-    role = forms.ChoiceField(
-        choices=add_blank_choice(IPAddressRoleChoices),
-        required=False,
-        widget=StaticSelect()
-    )
-    dns_name = forms.CharField(
-        max_length=255,
-        required=False
-    )
-    description = forms.CharField(
-        max_length=100,
-        required=False
-    )
-
-    class Meta:
-        nullable_fields = [
-            'vrf', 'role', 'tenant', 'dns_name', 'description',
-        ]
-
-
-class IPAddressAssignForm(BootstrapMixin, forms.Form):
-    vrf_id = DynamicModelChoiceField(
-        queryset=VRF.objects.all(),
-        required=False,
-        label='VRF'
-    )
-    q = forms.CharField(
-        required=False,
-        label='Search',
-    )
-
-
-class IPAddressFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
-    model = IPAddress
-    field_order = [
-        'q', 'parent', 'family', 'mask_length', 'vrf_id', 'present_in_vrf_id', 'status', 'role',
-        'assigned_to_interface', 'tenant_group_id', 'tenant_id',
-    ]
-    field_groups = [
-        ['q', 'tag'],
-        ['parent', 'family', 'status', 'role', 'mask_length', 'assigned_to_interface'],
-        ['vrf_id', 'present_in_vrf_id'],
-        ['tenant_group_id', 'tenant_id'],
-    ]
-    q = forms.CharField(
-        required=False,
-        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
-        label=_('Search')
-    )
-    parent = forms.CharField(
-        required=False,
-        widget=forms.TextInput(
-            attrs={
-                'placeholder': 'Prefix',
-            }
-        ),
-        label='Parent Prefix'
-    )
-    family = forms.ChoiceField(
-        required=False,
-        choices=add_blank_choice(IPAddressFamilyChoices),
-        label=_('Address family'),
-        widget=StaticSelect()
-    )
-    mask_length = forms.ChoiceField(
-        required=False,
-        choices=IPADDRESS_MASK_LENGTH_CHOICES,
-        label=_('Mask length'),
-        widget=StaticSelect()
-    )
-    vrf_id = DynamicModelMultipleChoiceField(
-        queryset=VRF.objects.all(),
-        required=False,
-        label=_('Assigned VRF'),
-        null_option='Global',
-        fetch_trigger='open'
-    )
-    present_in_vrf_id = DynamicModelChoiceField(
-        queryset=VRF.objects.all(),
-        required=False,
-        label=_('Present in VRF'),
-        fetch_trigger='open'
-    )
-    status = forms.MultipleChoiceField(
-        choices=IPAddressStatusChoices,
-        required=False,
-        widget=StaticSelectMultiple()
-    )
-    role = forms.MultipleChoiceField(
-        choices=IPAddressRoleChoices,
-        required=False,
-        widget=StaticSelectMultiple()
-    )
-    assigned_to_interface = forms.NullBooleanField(
-        required=False,
-        label=_('Assigned to an interface'),
-        widget=StaticSelect(
-            choices=BOOLEAN_WITH_BLANK_CHOICES
-        )
-    )
-    tag = TagFilterField(model)
-
-
-#
-# VLAN groups
-#
-
-class VLANGroupForm(BootstrapMixin, CustomFieldModelForm):
-    scope_type = ContentTypeChoiceField(
-        queryset=ContentType.objects.filter(model__in=VLANGROUP_SCOPE_TYPES),
-        required=False,
-        widget=StaticSelect
-    )
-    region = DynamicModelChoiceField(
-        queryset=Region.objects.all(),
-        required=False,
-        initial_params={
-            'sites': '$site'
-        }
-    )
-    sitegroup = DynamicModelChoiceField(
-        queryset=SiteGroup.objects.all(),
-        required=False,
-        initial_params={
-            'sites': '$site'
-        },
-        label='Site group'
-    )
-    site = DynamicModelChoiceField(
-        queryset=Site.objects.all(),
-        required=False,
-        initial_params={
-            'locations': '$location'
-        },
-        query_params={
-            'region_id': '$region',
-            'group_id': '$sitegroup',
-        }
-    )
-    location = DynamicModelChoiceField(
-        queryset=Location.objects.all(),
-        required=False,
-        initial_params={
-            'racks': '$rack'
-        },
-        query_params={
-            'site_id': '$site',
-        }
-    )
-    rack = DynamicModelChoiceField(
-        queryset=Rack.objects.all(),
-        required=False,
-        query_params={
-            'site_id': '$site',
-            'location_id': '$location',
-        }
-    )
-    clustergroup = DynamicModelChoiceField(
-        queryset=ClusterGroup.objects.all(),
-        required=False,
-        initial_params={
-            'clusters': '$cluster'
-        },
-        label='Cluster group'
-    )
-    cluster = DynamicModelChoiceField(
-        queryset=Cluster.objects.all(),
-        required=False,
-        query_params={
-            'group_id': '$clustergroup',
-        }
-    )
-    slug = SlugField()
-
-    class Meta:
-        model = VLANGroup
-        fields = [
-            'name', 'slug', 'description', 'scope_type', 'region', 'sitegroup', 'site', 'location', 'rack',
-            'clustergroup', 'cluster',
-        ]
-        fieldsets = (
-            ('VLAN Group', ('name', 'slug', 'description')),
-            ('Scope', ('scope_type', 'region', 'sitegroup', 'site', 'location', 'rack', 'clustergroup', 'cluster')),
-        )
-        widgets = {
-            'scope_type': StaticSelect,
-        }
-
-    def __init__(self, *args, **kwargs):
-        instance = kwargs.get('instance')
-        initial = kwargs.get('initial', {})
-
-        if instance is not None and instance.scope:
-            initial[instance.scope_type.model] = instance.scope
-
-            kwargs['initial'] = initial
-
-        super().__init__(*args, **kwargs)
-
-    def clean(self):
-        super().clean()
-
-        # Assign scope based on scope_type
-        if self.cleaned_data.get('scope_type'):
-            scope_field = self.cleaned_data['scope_type'].model
-            self.instance.scope = self.cleaned_data.get(scope_field)
-        else:
-            self.instance.scope_id = None
-
-
-class VLANGroupCSVForm(CustomFieldModelCSVForm):
-    slug = SlugField()
-    scope_type = CSVContentTypeField(
-        queryset=ContentType.objects.filter(model__in=VLANGROUP_SCOPE_TYPES),
-        required=False,
-        label='Scope type (app & model)'
-    )
-
-    class Meta:
-        model = VLANGroup
-        fields = ('name', 'slug', 'scope_type', 'scope_id', 'description')
-        labels = {
-            'scope_id': 'Scope ID',
-        }
-
-
-class VLANGroupBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=VLANGroup.objects.all(),
-        widget=forms.MultipleHiddenInput
-    )
-    site = DynamicModelChoiceField(
-        queryset=Site.objects.all(),
-        required=False
-    )
-    description = forms.CharField(
-        max_length=200,
-        required=False
-    )
-
-    class Meta:
-        nullable_fields = ['site', 'description']
-
-
-class VLANGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
-    field_groups = [
-        ['q'],
-        ['region', 'sitegroup', 'site', 'location', 'rack']
-    ]
-    model = VLANGroup
-    q = forms.CharField(
-        required=False,
-        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
-        label=_('Search')
-    )
-    region = DynamicModelMultipleChoiceField(
-        queryset=Region.objects.all(),
-        required=False,
-        label=_('Region'),
-        fetch_trigger='open'
-    )
-    sitegroup = DynamicModelMultipleChoiceField(
-        queryset=SiteGroup.objects.all(),
-        required=False,
-        label=_('Site group'),
-        fetch_trigger='open'
-    )
-    site = DynamicModelMultipleChoiceField(
-        queryset=Site.objects.all(),
-        required=False,
-        label=_('Site'),
-        fetch_trigger='open'
-    )
-    location = DynamicModelMultipleChoiceField(
-        queryset=Location.objects.all(),
-        required=False,
-        label=_('Location'),
-        fetch_trigger='open'
-    )
-    rack = DynamicModelMultipleChoiceField(
-        queryset=Rack.objects.all(),
-        required=False,
-        label=_('Rack'),
-        fetch_trigger='open'
-    )
-
-
-#
-# VLANs
-#
-
-class VLANForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
-    # VLANGroup assignment fields
-    scope_type = forms.ChoiceField(
-        choices=(
-            ('', ''),
-            ('dcim.region', 'Region'),
-            ('dcim.sitegroup', 'Site group'),
-            ('dcim.site', 'Site'),
-            ('dcim.location', 'Location'),
-            ('dcim.rack', 'Rack'),
-            ('virtualization.clustergroup', 'Cluster group'),
-            ('virtualization.cluster', 'Cluster'),
-        ),
-        required=False,
-        widget=StaticSelect,
-        label='Group scope'
-    )
-    group = DynamicModelChoiceField(
-        queryset=VLANGroup.objects.all(),
-        required=False,
-        query_params={
-            'scope_type': '$scope_type',
-        },
-        label='VLAN Group'
-    )
-
-    # Site assignment fields
-    region = DynamicModelChoiceField(
-        queryset=Region.objects.all(),
-        required=False,
-        initial_params={
-            'sites': '$site'
-        },
-        label='Region'
-    )
-    sitegroup = DynamicModelChoiceField(
-        queryset=SiteGroup.objects.all(),
-        required=False,
-        initial_params={
-            'sites': '$site'
-        },
-        label='Site group'
-    )
-    site = DynamicModelChoiceField(
-        queryset=Site.objects.all(),
-        required=False,
-        null_option='None',
-        query_params={
-            'region_id': '$region',
-            'group_id': '$sitegroup',
-        }
-    )
-
-    # Other fields
-    role = DynamicModelChoiceField(
-        queryset=Role.objects.all(),
-        required=False
-    )
-    tags = DynamicModelMultipleChoiceField(
-        queryset=Tag.objects.all(),
-        required=False
-    )
-
-    class Meta:
-        model = VLAN
-        fields = [
-            'site', 'group', 'vid', 'name', 'status', 'role', 'description', 'tenant_group', 'tenant', 'tags',
-        ]
-        help_texts = {
-            'site': "Leave blank if this VLAN spans multiple sites",
-            'group': "VLAN group (optional)",
-            'vid': "Configured VLAN ID",
-            'name': "Configured VLAN name",
-            'status': "Operational status of this VLAN",
-            'role': "The primary function of this VLAN",
-        }
-        widgets = {
-            'status': StaticSelect(),
-        }
-
-
-class VLANCSVForm(CustomFieldModelCSVForm):
-    site = CSVModelChoiceField(
-        queryset=Site.objects.all(),
-        required=False,
-        to_field_name='name',
-        help_text='Assigned site'
-    )
-    group = CSVModelChoiceField(
-        queryset=VLANGroup.objects.all(),
-        required=False,
-        to_field_name='name',
-        help_text='Assigned VLAN group'
-    )
-    tenant = CSVModelChoiceField(
-        queryset=Tenant.objects.all(),
-        to_field_name='name',
-        required=False,
-        help_text='Assigned tenant'
-    )
-    status = CSVChoiceField(
-        choices=VLANStatusChoices,
-        help_text='Operational status'
-    )
-    role = CSVModelChoiceField(
-        queryset=Role.objects.all(),
-        required=False,
-        to_field_name='name',
-        help_text='Functional role'
-    )
-
-    class Meta:
-        model = VLAN
-        fields = ('site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description')
-        help_texts = {
-            'vid': 'Numeric VLAN ID (1-4095)',
-            'name': 'VLAN name',
-        }
-
-
-class VLANBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=VLAN.objects.all(),
-        widget=forms.MultipleHiddenInput()
-    )
-    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',
-        }
-    )
-    group = DynamicModelChoiceField(
-        queryset=VLANGroup.objects.all(),
-        required=False,
-        query_params={
-            'site_id': '$site'
-        }
-    )
-    tenant = DynamicModelChoiceField(
-        queryset=Tenant.objects.all(),
-        required=False
-    )
-    status = forms.ChoiceField(
-        choices=add_blank_choice(VLANStatusChoices),
-        required=False,
-        widget=StaticSelect()
-    )
-    role = DynamicModelChoiceField(
-        queryset=Role.objects.all(),
-        required=False
-    )
-    description = forms.CharField(
-        max_length=100,
-        required=False
-    )
-
-    class Meta:
-        nullable_fields = [
-            'site', 'group', 'tenant', 'role', 'description',
-        ]
-
-
-class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
-    model = VLAN
-    field_groups = [
-        ['q', 'tag'],
-        ['region_id', 'site_group_id', 'site_id'],
-        ['group_id', 'status', 'role_id'],
-        ['tenant_group_id', 'tenant_id'],
-    ]
-    q = forms.CharField(
-        required=False,
-        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
-        label=_('Search')
-    )
-    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': '$region'
-        },
-        label=_('Site'),
-        fetch_trigger='open'
-    )
-    group_id = DynamicModelMultipleChoiceField(
-        queryset=VLANGroup.objects.all(),
-        required=False,
-        null_option='None',
-        query_params={
-            'region': '$region'
-        },
-        label=_('VLAN group'),
-        fetch_trigger='open'
-    )
-    status = forms.MultipleChoiceField(
-        choices=VLANStatusChoices,
-        required=False,
-        widget=StaticSelectMultiple()
-    )
-    role_id = DynamicModelMultipleChoiceField(
-        queryset=Role.objects.all(),
-        required=False,
-        null_option='None',
-        label=_('Role'),
-        fetch_trigger='open'
-    )
-    tag = TagFilterField(model)
-
-
-#
-# Services
-#
-
-class ServiceForm(BootstrapMixin, CustomFieldModelForm):
-    ports = NumericArrayField(
-        base_field=forms.IntegerField(
-            min_value=SERVICE_PORT_MIN,
-            max_value=SERVICE_PORT_MAX
-        ),
-        help_text="Comma-separated list of one or more port numbers. A range may be specified using a hyphen."
-    )
-    tags = DynamicModelMultipleChoiceField(
-        queryset=Tag.objects.all(),
-        required=False
-    )
-
-    class Meta:
-        model = Service
-        fields = [
-            'name', 'protocol', 'ports', 'ipaddresses', 'description', 'tags',
-        ]
-        help_texts = {
-            'ipaddresses': "IP address assignment is optional. If no IPs are selected, the service is assumed to be "
-                           "reachable via all IPs assigned to the device.",
-        }
-        widgets = {
-            'protocol': StaticSelect(),
-            'ipaddresses': StaticSelectMultiple(),
-        }
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-
-        # Limit IP address choices to those assigned to interfaces of the parent device/VM
-        if self.instance.device:
-            self.fields['ipaddresses'].queryset = IPAddress.objects.filter(
-                interface__in=self.instance.device.vc_interfaces().values_list('id', flat=True)
-            )
-        elif self.instance.virtual_machine:
-            self.fields['ipaddresses'].queryset = IPAddress.objects.filter(
-                vminterface__in=self.instance.virtual_machine.interfaces.values_list('id', flat=True)
-            )
-        else:
-            self.fields['ipaddresses'].choices = []
-
-
-class ServiceFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
-    model = Service
-    field_groups = (
-        ('q', 'tag'),
-        ('protocol', 'port'),
-    )
-    q = forms.CharField(
-        required=False,
-        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
-        label=_('Search')
-    )
-    protocol = forms.ChoiceField(
-        choices=add_blank_choice(ServiceProtocolChoices),
-        required=False,
-        widget=StaticSelectMultiple()
-    )
-    port = forms.IntegerField(
-        required=False,
-    )
-    tag = TagFilterField(model)
-
-
-class ServiceCSVForm(CustomFieldModelCSVForm):
-    device = CSVModelChoiceField(
-        queryset=Device.objects.all(),
-        required=False,
-        to_field_name='name',
-        help_text='Required if not assigned to a VM'
-    )
-    virtual_machine = CSVModelChoiceField(
-        queryset=VirtualMachine.objects.all(),
-        required=False,
-        to_field_name='name',
-        help_text='Required if not assigned to a device'
-    )
-    protocol = CSVChoiceField(
-        choices=ServiceProtocolChoices,
-        help_text='IP protocol'
-    )
-
-    class Meta:
-        model = Service
-        fields = ('device', 'virtual_machine', 'name', 'protocol', 'ports', 'description')
-
-
-class ServiceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=Service.objects.all(),
-        widget=forms.MultipleHiddenInput()
-    )
-    protocol = forms.ChoiceField(
-        choices=add_blank_choice(ServiceProtocolChoices),
-        required=False,
-        widget=StaticSelect()
-    )
-    ports = NumericArrayField(
-        base_field=forms.IntegerField(
-            min_value=SERVICE_PORT_MIN,
-            max_value=SERVICE_PORT_MAX
-        ),
-        required=False
-    )
-    description = forms.CharField(
-        max_length=100,
-        required=False
-    )
-
-    class Meta:
-        nullable_fields = [
-            'description',
-        ]

+ 5 - 0
netbox/ipam/forms/__init__.py

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

+ 13 - 0
netbox/ipam/forms/bulk_create.py

@@ -0,0 +1,13 @@
+from django import forms
+
+from utilities.forms import BootstrapMixin, ExpandableIPAddressField
+
+__all__ = (
+    'IPAddressBulkCreateForm',
+)
+
+
+class IPAddressBulkCreateForm(BootstrapMixin, forms.Form):
+    pattern = ExpandableIPAddressField(
+        label='Address pattern'
+    )

+ 378 - 0
netbox/ipam/forms/bulk_edit.py

@@ -0,0 +1,378 @@
+from django import forms
+
+from dcim.models import Region, Site, SiteGroup
+from extras.forms import AddRemoveTagsForm, CustomFieldModelBulkEditForm
+from ipam.choices import *
+from ipam.constants import *
+from ipam.models import *
+from tenancy.models import Tenant
+from utilities.forms import (
+    add_blank_choice, BootstrapMixin, BulkEditNullBooleanSelect, DatePicker, DynamicModelChoiceField, NumericArrayField,
+    StaticSelect,
+)
+
+__all__ = (
+    'AggregateBulkEditForm',
+    'IPAddressBulkEditForm',
+    'IPRangeBulkEditForm',
+    'PrefixBulkEditForm',
+    'RIRBulkEditForm',
+    'RoleBulkEditForm',
+    'RouteTargetBulkEditForm',
+    'ServiceBulkEditForm',
+    'VLANBulkEditForm',
+    'VLANGroupBulkEditForm',
+    'VRFBulkEditForm',
+)
+
+
+class VRFBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=VRF.objects.all(),
+        widget=forms.MultipleHiddenInput()
+    )
+    tenant = DynamicModelChoiceField(
+        queryset=Tenant.objects.all(),
+        required=False
+    )
+    enforce_unique = forms.NullBooleanField(
+        required=False,
+        widget=BulkEditNullBooleanSelect(),
+        label='Enforce unique space'
+    )
+    description = forms.CharField(
+        max_length=100,
+        required=False
+    )
+
+    class Meta:
+        nullable_fields = [
+            'tenant', 'description',
+        ]
+
+
+class RouteTargetBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=RouteTarget.objects.all(),
+        widget=forms.MultipleHiddenInput()
+    )
+    tenant = DynamicModelChoiceField(
+        queryset=Tenant.objects.all(),
+        required=False
+    )
+    description = forms.CharField(
+        max_length=200,
+        required=False
+    )
+
+    class Meta:
+        nullable_fields = [
+            'tenant', 'description',
+        ]
+
+
+class RIRBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=RIR.objects.all(),
+        widget=forms.MultipleHiddenInput
+    )
+    is_private = forms.NullBooleanField(
+        required=False,
+        widget=BulkEditNullBooleanSelect
+    )
+    description = forms.CharField(
+        max_length=200,
+        required=False
+    )
+
+    class Meta:
+        nullable_fields = ['is_private', 'description']
+
+
+class AggregateBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=Aggregate.objects.all(),
+        widget=forms.MultipleHiddenInput()
+    )
+    rir = DynamicModelChoiceField(
+        queryset=RIR.objects.all(),
+        required=False,
+        label='RIR'
+    )
+    tenant = DynamicModelChoiceField(
+        queryset=Tenant.objects.all(),
+        required=False
+    )
+    date_added = forms.DateField(
+        required=False
+    )
+    description = forms.CharField(
+        max_length=100,
+        required=False
+    )
+
+    class Meta:
+        nullable_fields = [
+            'date_added', 'description',
+        ]
+        widgets = {
+            'date_added': DatePicker(),
+        }
+
+
+class RoleBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=Role.objects.all(),
+        widget=forms.MultipleHiddenInput
+    )
+    weight = forms.IntegerField(
+        required=False
+    )
+    description = forms.CharField(
+        max_length=200,
+        required=False
+    )
+
+    class Meta:
+        nullable_fields = ['description']
+
+
+class PrefixBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=Prefix.objects.all(),
+        widget=forms.MultipleHiddenInput()
+    )
+    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',
+        }
+    )
+    vrf = DynamicModelChoiceField(
+        queryset=VRF.objects.all(),
+        required=False,
+        label='VRF'
+    )
+    prefix_length = forms.IntegerField(
+        min_value=PREFIX_LENGTH_MIN,
+        max_value=PREFIX_LENGTH_MAX,
+        required=False
+    )
+    tenant = DynamicModelChoiceField(
+        queryset=Tenant.objects.all(),
+        required=False
+    )
+    status = forms.ChoiceField(
+        choices=add_blank_choice(PrefixStatusChoices),
+        required=False,
+        widget=StaticSelect()
+    )
+    role = DynamicModelChoiceField(
+        queryset=Role.objects.all(),
+        required=False
+    )
+    is_pool = forms.NullBooleanField(
+        required=False,
+        widget=BulkEditNullBooleanSelect(),
+        label='Is a pool'
+    )
+    mark_utilized = forms.NullBooleanField(
+        required=False,
+        widget=BulkEditNullBooleanSelect(),
+        label='Treat as 100% utilized'
+    )
+    description = forms.CharField(
+        max_length=100,
+        required=False
+    )
+
+    class Meta:
+        nullable_fields = [
+            'site', 'vrf', 'tenant', 'role', 'description',
+        ]
+
+
+class IPRangeBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=IPRange.objects.all(),
+        widget=forms.MultipleHiddenInput()
+    )
+    vrf = DynamicModelChoiceField(
+        queryset=VRF.objects.all(),
+        required=False,
+        label='VRF'
+    )
+    tenant = DynamicModelChoiceField(
+        queryset=Tenant.objects.all(),
+        required=False
+    )
+    status = forms.ChoiceField(
+        choices=add_blank_choice(IPRangeStatusChoices),
+        required=False,
+        widget=StaticSelect()
+    )
+    role = DynamicModelChoiceField(
+        queryset=Role.objects.all(),
+        required=False
+    )
+    description = forms.CharField(
+        max_length=100,
+        required=False
+    )
+
+    class Meta:
+        nullable_fields = [
+            'vrf', 'tenant', 'role', 'description',
+        ]
+
+
+class IPAddressBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=IPAddress.objects.all(),
+        widget=forms.MultipleHiddenInput()
+    )
+    vrf = DynamicModelChoiceField(
+        queryset=VRF.objects.all(),
+        required=False,
+        label='VRF'
+    )
+    mask_length = forms.IntegerField(
+        min_value=IPADDRESS_MASK_LENGTH_MIN,
+        max_value=IPADDRESS_MASK_LENGTH_MAX,
+        required=False
+    )
+    tenant = DynamicModelChoiceField(
+        queryset=Tenant.objects.all(),
+        required=False
+    )
+    status = forms.ChoiceField(
+        choices=add_blank_choice(IPAddressStatusChoices),
+        required=False,
+        widget=StaticSelect()
+    )
+    role = forms.ChoiceField(
+        choices=add_blank_choice(IPAddressRoleChoices),
+        required=False,
+        widget=StaticSelect()
+    )
+    dns_name = forms.CharField(
+        max_length=255,
+        required=False
+    )
+    description = forms.CharField(
+        max_length=100,
+        required=False
+    )
+
+    class Meta:
+        nullable_fields = [
+            'vrf', 'role', 'tenant', 'dns_name', 'description',
+        ]
+
+
+class VLANGroupBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=VLANGroup.objects.all(),
+        widget=forms.MultipleHiddenInput
+    )
+    site = DynamicModelChoiceField(
+        queryset=Site.objects.all(),
+        required=False
+    )
+    description = forms.CharField(
+        max_length=200,
+        required=False
+    )
+
+    class Meta:
+        nullable_fields = ['site', 'description']
+
+
+class VLANBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=VLAN.objects.all(),
+        widget=forms.MultipleHiddenInput()
+    )
+    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',
+        }
+    )
+    group = DynamicModelChoiceField(
+        queryset=VLANGroup.objects.all(),
+        required=False,
+        query_params={
+            'site_id': '$site'
+        }
+    )
+    tenant = DynamicModelChoiceField(
+        queryset=Tenant.objects.all(),
+        required=False
+    )
+    status = forms.ChoiceField(
+        choices=add_blank_choice(VLANStatusChoices),
+        required=False,
+        widget=StaticSelect()
+    )
+    role = DynamicModelChoiceField(
+        queryset=Role.objects.all(),
+        required=False
+    )
+    description = forms.CharField(
+        max_length=100,
+        required=False
+    )
+
+    class Meta:
+        nullable_fields = [
+            'site', 'group', 'tenant', 'role', 'description',
+        ]
+
+
+class ServiceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=Service.objects.all(),
+        widget=forms.MultipleHiddenInput()
+    )
+    protocol = forms.ChoiceField(
+        choices=add_blank_choice(ServiceProtocolChoices),
+        required=False,
+        widget=StaticSelect()
+    )
+    ports = NumericArrayField(
+        base_field=forms.IntegerField(
+            min_value=SERVICE_PORT_MIN,
+            max_value=SERVICE_PORT_MAX
+        ),
+        required=False
+    )
+    description = forms.CharField(
+        max_length=100,
+        required=False
+    )
+
+    class Meta:
+        nullable_fields = [
+            'description',
+        ]

+ 362 - 0
netbox/ipam/forms/bulk_import.py

@@ -0,0 +1,362 @@
+from django import forms
+from django.contrib.contenttypes.models import ContentType
+
+from dcim.models import Device, Interface, Site
+from extras.forms import CustomFieldModelCSVForm
+from ipam.choices import *
+from ipam.constants import *
+from ipam.models import *
+from tenancy.models import Tenant
+from utilities.forms import CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, SlugField
+from virtualization.models import VirtualMachine, VMInterface
+
+__all__ = (
+    'AggregateCSVForm',
+    'IPAddressCSVForm',
+    'IPRangeCSVForm',
+    'PrefixCSVForm',
+    'RIRCSVForm',
+    'RoleCSVForm',
+    'RouteTargetCSVForm',
+    'ServiceCSVForm',
+    'VLANCSVForm',
+    'VLANGroupCSVForm',
+    'VRFCSVForm',
+)
+
+
+class VRFCSVForm(CustomFieldModelCSVForm):
+    tenant = CSVModelChoiceField(
+        queryset=Tenant.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text='Assigned tenant'
+    )
+
+    class Meta:
+        model = VRF
+        fields = ('name', 'rd', 'tenant', 'enforce_unique', 'description')
+
+
+class RouteTargetCSVForm(CustomFieldModelCSVForm):
+    tenant = CSVModelChoiceField(
+        queryset=Tenant.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text='Assigned tenant'
+    )
+
+    class Meta:
+        model = RouteTarget
+        fields = ('name', 'description', 'tenant')
+
+
+class RIRCSVForm(CustomFieldModelCSVForm):
+    slug = SlugField()
+
+    class Meta:
+        model = RIR
+        fields = ('name', 'slug', 'is_private', 'description')
+        help_texts = {
+            'name': 'RIR name',
+        }
+
+
+class AggregateCSVForm(CustomFieldModelCSVForm):
+    rir = CSVModelChoiceField(
+        queryset=RIR.objects.all(),
+        to_field_name='name',
+        help_text='Assigned RIR'
+    )
+    tenant = CSVModelChoiceField(
+        queryset=Tenant.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text='Assigned tenant'
+    )
+
+    class Meta:
+        model = Aggregate
+        fields = ('prefix', 'rir', 'tenant', 'date_added', 'description')
+
+
+class RoleCSVForm(CustomFieldModelCSVForm):
+    slug = SlugField()
+
+    class Meta:
+        model = Role
+        fields = ('name', 'slug', 'weight', 'description')
+
+
+class PrefixCSVForm(CustomFieldModelCSVForm):
+    vrf = CSVModelChoiceField(
+        queryset=VRF.objects.all(),
+        to_field_name='name',
+        required=False,
+        help_text='Assigned VRF'
+    )
+    tenant = CSVModelChoiceField(
+        queryset=Tenant.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text='Assigned tenant'
+    )
+    site = CSVModelChoiceField(
+        queryset=Site.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text='Assigned site'
+    )
+    vlan_group = CSVModelChoiceField(
+        queryset=VLANGroup.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text="VLAN's group (if any)"
+    )
+    vlan = CSVModelChoiceField(
+        queryset=VLAN.objects.all(),
+        required=False,
+        to_field_name='vid',
+        help_text="Assigned VLAN"
+    )
+    status = CSVChoiceField(
+        choices=PrefixStatusChoices,
+        help_text='Operational status'
+    )
+    role = CSVModelChoiceField(
+        queryset=Role.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text='Functional role'
+    )
+
+    class Meta:
+        model = Prefix
+        fields = (
+            'prefix', 'vrf', 'tenant', 'site', 'vlan_group', 'vlan', 'status', 'role', 'is_pool', 'mark_utilized',
+            'description',
+        )
+
+    def __init__(self, data=None, *args, **kwargs):
+        super().__init__(data, *args, **kwargs)
+
+        if data:
+
+            # Limit VLAN queryset by assigned site and/or group (if specified)
+            params = {}
+            if data.get('site'):
+                params[f"site__{self.fields['site'].to_field_name}"] = data.get('site')
+            if data.get('vlan_group'):
+                params[f"group__{self.fields['vlan_group'].to_field_name}"] = data.get('vlan_group')
+            if params:
+                self.fields['vlan'].queryset = self.fields['vlan'].queryset.filter(**params)
+
+
+class IPRangeCSVForm(CustomFieldModelCSVForm):
+    vrf = CSVModelChoiceField(
+        queryset=VRF.objects.all(),
+        to_field_name='name',
+        required=False,
+        help_text='Assigned VRF'
+    )
+    tenant = CSVModelChoiceField(
+        queryset=Tenant.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text='Assigned tenant'
+    )
+    status = CSVChoiceField(
+        choices=IPRangeStatusChoices,
+        help_text='Operational status'
+    )
+    role = CSVModelChoiceField(
+        queryset=Role.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text='Functional role'
+    )
+
+    class Meta:
+        model = IPRange
+        fields = (
+            'start_address', 'end_address', 'vrf', 'tenant', 'status', 'role', 'description',
+        )
+
+
+class IPAddressCSVForm(CustomFieldModelCSVForm):
+    vrf = CSVModelChoiceField(
+        queryset=VRF.objects.all(),
+        to_field_name='name',
+        required=False,
+        help_text='Assigned VRF'
+    )
+    tenant = CSVModelChoiceField(
+        queryset=Tenant.objects.all(),
+        to_field_name='name',
+        required=False,
+        help_text='Assigned tenant'
+    )
+    status = CSVChoiceField(
+        choices=IPAddressStatusChoices,
+        required=False,
+        help_text='Operational status'
+    )
+    role = CSVChoiceField(
+        choices=IPAddressRoleChoices,
+        required=False,
+        help_text='Functional role'
+    )
+    device = CSVModelChoiceField(
+        queryset=Device.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text='Parent device of assigned interface (if any)'
+    )
+    virtual_machine = CSVModelChoiceField(
+        queryset=VirtualMachine.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text='Parent VM of assigned interface (if any)'
+    )
+    interface = CSVModelChoiceField(
+        queryset=Interface.objects.none(),  # Can also refer to VMInterface
+        required=False,
+        to_field_name='name',
+        help_text='Assigned interface'
+    )
+    is_primary = forms.BooleanField(
+        help_text='Make this the primary IP for the assigned device',
+        required=False
+    )
+
+    class Meta:
+        model = IPAddress
+        fields = [
+            'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface', 'is_primary',
+            'dns_name', 'description',
+        ]
+
+    def __init__(self, data=None, *args, **kwargs):
+        super().__init__(data, *args, **kwargs)
+
+        if data:
+
+            # Limit interface queryset by assigned device
+            if data.get('device'):
+                self.fields['interface'].queryset = Interface.objects.filter(
+                    **{f"device__{self.fields['device'].to_field_name}": data['device']}
+                )
+
+            # Limit interface queryset by assigned device
+            elif data.get('virtual_machine'):
+                self.fields['interface'].queryset = VMInterface.objects.filter(
+                    **{f"virtual_machine__{self.fields['virtual_machine'].to_field_name}": data['virtual_machine']}
+                )
+
+    def clean(self):
+        super().clean()
+
+        device = self.cleaned_data.get('device')
+        virtual_machine = self.cleaned_data.get('virtual_machine')
+        is_primary = self.cleaned_data.get('is_primary')
+
+        # Validate is_primary
+        if is_primary and not device and not virtual_machine:
+            raise forms.ValidationError("No device or virtual machine specified; cannot set as primary IP")
+
+    def save(self, *args, **kwargs):
+
+        # Set interface assignment
+        if self.cleaned_data['interface']:
+            self.instance.assigned_object = self.cleaned_data['interface']
+
+        ipaddress = super().save(*args, **kwargs)
+
+        # Set as primary for device/VM
+        if self.cleaned_data['is_primary']:
+            parent = self.cleaned_data['device'] or self.cleaned_data['virtual_machine']
+            if self.instance.address.version == 4:
+                parent.primary_ip4 = ipaddress
+            elif self.instance.address.version == 6:
+                parent.primary_ip6 = ipaddress
+            parent.save()
+
+        return ipaddress
+
+
+class VLANGroupCSVForm(CustomFieldModelCSVForm):
+    slug = SlugField()
+    scope_type = CSVContentTypeField(
+        queryset=ContentType.objects.filter(model__in=VLANGROUP_SCOPE_TYPES),
+        required=False,
+        label='Scope type (app & model)'
+    )
+
+    class Meta:
+        model = VLANGroup
+        fields = ('name', 'slug', 'scope_type', 'scope_id', 'description')
+        labels = {
+            'scope_id': 'Scope ID',
+        }
+
+
+class VLANCSVForm(CustomFieldModelCSVForm):
+    site = CSVModelChoiceField(
+        queryset=Site.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text='Assigned site'
+    )
+    group = CSVModelChoiceField(
+        queryset=VLANGroup.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text='Assigned VLAN group'
+    )
+    tenant = CSVModelChoiceField(
+        queryset=Tenant.objects.all(),
+        to_field_name='name',
+        required=False,
+        help_text='Assigned tenant'
+    )
+    status = CSVChoiceField(
+        choices=VLANStatusChoices,
+        help_text='Operational status'
+    )
+    role = CSVModelChoiceField(
+        queryset=Role.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text='Functional role'
+    )
+
+    class Meta:
+        model = VLAN
+        fields = ('site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description')
+        help_texts = {
+            'vid': 'Numeric VLAN ID (1-4095)',
+            'name': 'VLAN name',
+        }
+
+
+class ServiceCSVForm(CustomFieldModelCSVForm):
+    device = CSVModelChoiceField(
+        queryset=Device.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text='Required if not assigned to a VM'
+    )
+    virtual_machine = CSVModelChoiceField(
+        queryset=VirtualMachine.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text='Required if not assigned to a device'
+    )
+    protocol = CSVChoiceField(
+        choices=ServiceProtocolChoices,
+        help_text='IP protocol'
+    )
+
+    class Meta:
+        model = Service
+        fields = ('device', 'virtual_machine', 'name', 'protocol', 'ports', 'description')

+ 486 - 0
netbox/ipam/forms/filtersets.py

@@ -0,0 +1,486 @@
+from django import forms
+from django.utils.translation import gettext as _
+
+from dcim.models import Location, Rack, Region, Site, SiteGroup
+from extras.forms import CustomFieldModelFilterForm
+from ipam.choices import *
+from ipam.constants import *
+from ipam.models import *
+from tenancy.forms import TenancyFilterForm
+from utilities.forms import (
+    add_blank_choice, BootstrapMixin, DynamicModelChoiceField, DynamicModelMultipleChoiceField, StaticSelect,
+    StaticSelectMultiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES,
+)
+
+__all__ = (
+    'AggregateFilterForm',
+    'IPAddressFilterForm',
+    'IPRangeFilterForm',
+    'PrefixFilterForm',
+    'RIRFilterForm',
+    'RoleFilterForm',
+    'RouteTargetFilterForm',
+    'ServiceFilterForm',
+    'VLANFilterForm',
+    'VLANGroupFilterForm',
+    'VRFFilterForm',
+)
+
+PREFIX_MASK_LENGTH_CHOICES = add_blank_choice([
+    (i, i) for i in range(PREFIX_LENGTH_MIN, PREFIX_LENGTH_MAX + 1)
+])
+
+IPADDRESS_MASK_LENGTH_CHOICES = add_blank_choice([
+    (i, i) for i in range(IPADDRESS_MASK_LENGTH_MIN, IPADDRESS_MASK_LENGTH_MAX + 1)
+])
+
+
+class VRFFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
+    model = VRF
+    field_groups = [
+        ['q', 'tag'],
+        ['import_target_id', 'export_target_id'],
+        ['tenant_group_id', 'tenant_id'],
+    ]
+    q = forms.CharField(
+        required=False,
+        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
+        label=_('Search')
+    )
+    import_target_id = DynamicModelMultipleChoiceField(
+        queryset=RouteTarget.objects.all(),
+        required=False,
+        label=_('Import targets'),
+        fetch_trigger='open'
+    )
+    export_target_id = DynamicModelMultipleChoiceField(
+        queryset=RouteTarget.objects.all(),
+        required=False,
+        label=_('Export targets'),
+        fetch_trigger='open'
+    )
+    tag = TagFilterField(model)
+
+
+class RouteTargetFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
+    model = RouteTarget
+    field_groups = [
+        ['q', 'tag'],
+        ['importing_vrf_id', 'exporting_vrf_id'],
+        ['tenant_group_id', 'tenant_id'],
+    ]
+    q = forms.CharField(
+        required=False,
+        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
+        label=_('Search')
+    )
+    importing_vrf_id = DynamicModelMultipleChoiceField(
+        queryset=VRF.objects.all(),
+        required=False,
+        label=_('Imported by VRF'),
+        fetch_trigger='open'
+    )
+    exporting_vrf_id = DynamicModelMultipleChoiceField(
+        queryset=VRF.objects.all(),
+        required=False,
+        label=_('Exported by VRF'),
+        fetch_trigger='open'
+    )
+    tag = TagFilterField(model)
+
+
+class RIRFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
+    model = RIR
+    field_groups = [
+        ['q'],
+        ['is_private'],
+    ]
+    q = forms.CharField(
+        required=False,
+        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
+        label=_('Search')
+    )
+    is_private = forms.NullBooleanField(
+        required=False,
+        label=_('Private'),
+        widget=StaticSelect(
+            choices=BOOLEAN_WITH_BLANK_CHOICES
+        )
+    )
+
+
+class AggregateFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
+    model = Aggregate
+    field_groups = [
+        ['q', 'tag'],
+        ['family', 'rir_id'],
+        ['tenant_group_id', 'tenant_id']
+    ]
+    q = forms.CharField(
+        required=False,
+        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
+        label=_('Search')
+    )
+    family = forms.ChoiceField(
+        required=False,
+        choices=add_blank_choice(IPAddressFamilyChoices),
+        label=_('Address family'),
+        widget=StaticSelect()
+    )
+    rir_id = DynamicModelMultipleChoiceField(
+        queryset=RIR.objects.all(),
+        required=False,
+        label=_('RIR'),
+        fetch_trigger='open'
+    )
+    tag = TagFilterField(model)
+
+
+class RoleFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
+    model = Role
+    field_groups = [
+        ['q'],
+    ]
+    q = forms.CharField(
+        required=False,
+        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
+        label=_('Search')
+    )
+
+
+class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
+    model = Prefix
+    field_groups = [
+        ['q', 'tag'],
+        ['within_include', 'family', 'status', 'role_id', 'mask_length', 'is_pool', 'mark_utilized'],
+        ['vrf_id', 'present_in_vrf_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')
+    )
+    mask_length__lte = forms.IntegerField(
+        widget=forms.HiddenInput()
+    )
+    within_include = forms.CharField(
+        required=False,
+        widget=forms.TextInput(
+            attrs={
+                'placeholder': 'Prefix',
+            }
+        ),
+        label=_('Search within')
+    )
+    family = forms.ChoiceField(
+        required=False,
+        choices=add_blank_choice(IPAddressFamilyChoices),
+        label=_('Address family'),
+        widget=StaticSelect()
+    )
+    mask_length = forms.MultipleChoiceField(
+        required=False,
+        choices=PREFIX_MASK_LENGTH_CHOICES,
+        label=_('Mask length'),
+        widget=StaticSelectMultiple()
+    )
+    vrf_id = DynamicModelMultipleChoiceField(
+        queryset=VRF.objects.all(),
+        required=False,
+        label=_('Assigned VRF'),
+        null_option='Global',
+        fetch_trigger='open'
+    )
+    present_in_vrf_id = DynamicModelChoiceField(
+        queryset=VRF.objects.all(),
+        required=False,
+        label=_('Present in VRF'),
+        fetch_trigger='open'
+    )
+    status = forms.MultipleChoiceField(
+        choices=PrefixStatusChoices,
+        required=False,
+        widget=StaticSelectMultiple()
+    )
+    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'
+        },
+        label=_('Site'),
+        fetch_trigger='open'
+    )
+    role_id = DynamicModelMultipleChoiceField(
+        queryset=Role.objects.all(),
+        required=False,
+        null_option='None',
+        label=_('Role'),
+        fetch_trigger='open'
+    )
+    is_pool = forms.NullBooleanField(
+        required=False,
+        label=_('Is a pool'),
+        widget=StaticSelect(
+            choices=BOOLEAN_WITH_BLANK_CHOICES
+        )
+    )
+    mark_utilized = forms.NullBooleanField(
+        required=False,
+        label=_('Marked as 100% utilized'),
+        widget=StaticSelect(
+            choices=BOOLEAN_WITH_BLANK_CHOICES
+        )
+    )
+    tag = TagFilterField(model)
+
+
+class IPRangeFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
+    model = IPRange
+    field_groups = [
+        ['q', 'tag'],
+        ['family', 'vrf_id', 'status', 'role_id'],
+        ['tenant_group_id', 'tenant_id'],
+    ]
+    q = forms.CharField(
+        required=False,
+        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
+        label=_('Search')
+    )
+    family = forms.ChoiceField(
+        required=False,
+        choices=add_blank_choice(IPAddressFamilyChoices),
+        label=_('Address family'),
+        widget=StaticSelect()
+    )
+    vrf_id = DynamicModelMultipleChoiceField(
+        queryset=VRF.objects.all(),
+        required=False,
+        label=_('Assigned VRF'),
+        null_option='Global',
+        fetch_trigger='open'
+    )
+    status = forms.MultipleChoiceField(
+        choices=PrefixStatusChoices,
+        required=False,
+        widget=StaticSelectMultiple()
+    )
+    role_id = DynamicModelMultipleChoiceField(
+        queryset=Role.objects.all(),
+        required=False,
+        null_option='None',
+        label=_('Role'),
+        fetch_trigger='open'
+    )
+    tag = TagFilterField(model)
+
+
+class IPAddressFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
+    model = IPAddress
+    field_order = [
+        'q', 'parent', 'family', 'mask_length', 'vrf_id', 'present_in_vrf_id', 'status', 'role',
+        'assigned_to_interface', 'tenant_group_id', 'tenant_id',
+    ]
+    field_groups = [
+        ['q', 'tag'],
+        ['parent', 'family', 'status', 'role', 'mask_length', 'assigned_to_interface'],
+        ['vrf_id', 'present_in_vrf_id'],
+        ['tenant_group_id', 'tenant_id'],
+    ]
+    q = forms.CharField(
+        required=False,
+        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
+        label=_('Search')
+    )
+    parent = forms.CharField(
+        required=False,
+        widget=forms.TextInput(
+            attrs={
+                'placeholder': 'Prefix',
+            }
+        ),
+        label='Parent Prefix'
+    )
+    family = forms.ChoiceField(
+        required=False,
+        choices=add_blank_choice(IPAddressFamilyChoices),
+        label=_('Address family'),
+        widget=StaticSelect()
+    )
+    mask_length = forms.ChoiceField(
+        required=False,
+        choices=IPADDRESS_MASK_LENGTH_CHOICES,
+        label=_('Mask length'),
+        widget=StaticSelect()
+    )
+    vrf_id = DynamicModelMultipleChoiceField(
+        queryset=VRF.objects.all(),
+        required=False,
+        label=_('Assigned VRF'),
+        null_option='Global',
+        fetch_trigger='open'
+    )
+    present_in_vrf_id = DynamicModelChoiceField(
+        queryset=VRF.objects.all(),
+        required=False,
+        label=_('Present in VRF'),
+        fetch_trigger='open'
+    )
+    status = forms.MultipleChoiceField(
+        choices=IPAddressStatusChoices,
+        required=False,
+        widget=StaticSelectMultiple()
+    )
+    role = forms.MultipleChoiceField(
+        choices=IPAddressRoleChoices,
+        required=False,
+        widget=StaticSelectMultiple()
+    )
+    assigned_to_interface = forms.NullBooleanField(
+        required=False,
+        label=_('Assigned to an interface'),
+        widget=StaticSelect(
+            choices=BOOLEAN_WITH_BLANK_CHOICES
+        )
+    )
+    tag = TagFilterField(model)
+
+
+class VLANGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
+    field_groups = [
+        ['q'],
+        ['region', 'sitegroup', 'site', 'location', 'rack']
+    ]
+    model = VLANGroup
+    q = forms.CharField(
+        required=False,
+        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
+        label=_('Search')
+    )
+    region = DynamicModelMultipleChoiceField(
+        queryset=Region.objects.all(),
+        required=False,
+        label=_('Region'),
+        fetch_trigger='open'
+    )
+    sitegroup = DynamicModelMultipleChoiceField(
+        queryset=SiteGroup.objects.all(),
+        required=False,
+        label=_('Site group'),
+        fetch_trigger='open'
+    )
+    site = DynamicModelMultipleChoiceField(
+        queryset=Site.objects.all(),
+        required=False,
+        label=_('Site'),
+        fetch_trigger='open'
+    )
+    location = DynamicModelMultipleChoiceField(
+        queryset=Location.objects.all(),
+        required=False,
+        label=_('Location'),
+        fetch_trigger='open'
+    )
+    rack = DynamicModelMultipleChoiceField(
+        queryset=Rack.objects.all(),
+        required=False,
+        label=_('Rack'),
+        fetch_trigger='open'
+    )
+
+
+class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
+    model = VLAN
+    field_groups = [
+        ['q', 'tag'],
+        ['region_id', 'site_group_id', 'site_id'],
+        ['group_id', 'status', 'role_id'],
+        ['tenant_group_id', 'tenant_id'],
+    ]
+    q = forms.CharField(
+        required=False,
+        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
+        label=_('Search')
+    )
+    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': '$region'
+        },
+        label=_('Site'),
+        fetch_trigger='open'
+    )
+    group_id = DynamicModelMultipleChoiceField(
+        queryset=VLANGroup.objects.all(),
+        required=False,
+        null_option='None',
+        query_params={
+            'region': '$region'
+        },
+        label=_('VLAN group'),
+        fetch_trigger='open'
+    )
+    status = forms.MultipleChoiceField(
+        choices=VLANStatusChoices,
+        required=False,
+        widget=StaticSelectMultiple()
+    )
+    role_id = DynamicModelMultipleChoiceField(
+        queryset=Role.objects.all(),
+        required=False,
+        null_option='None',
+        label=_('Role'),
+        fetch_trigger='open'
+    )
+    tag = TagFilterField(model)
+
+
+class ServiceFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
+    model = Service
+    field_groups = (
+        ('q', 'tag'),
+        ('protocol', 'port'),
+    )
+    q = forms.CharField(
+        required=False,
+        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
+        label=_('Search')
+    )
+    protocol = forms.ChoiceField(
+        choices=add_blank_choice(ServiceProtocolChoices),
+        required=False,
+        widget=StaticSelectMultiple()
+    )
+    port = forms.IntegerField(
+        required=False,
+    )
+    tag = TagFilterField(model)

+ 691 - 0
netbox/ipam/forms/models.py

@@ -0,0 +1,691 @@
+from django import forms
+from django.contrib.contenttypes.models import ContentType
+
+from dcim.models import Device, Interface, Location, Rack, Region, Site, SiteGroup
+from extras.forms import CustomFieldModelForm
+from extras.models import Tag
+from ipam.constants import *
+from ipam.models import *
+from tenancy.forms import TenancyForm
+from utilities.forms import (
+    BootstrapMixin, ContentTypeChoiceField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
+    NumericArrayField, SlugField, StaticSelect, StaticSelectMultiple,
+)
+from virtualization.models import Cluster, ClusterGroup, VirtualMachine, VMInterface
+
+__all__ = (
+    'AggregateForm',
+    'IPAddressAssignForm',
+    'IPAddressBulkAddForm',
+    'IPAddressForm',
+    'IPRangeForm',
+    'PrefixForm',
+    'RIRForm',
+    'RoleForm',
+    'RouteTargetForm',
+    'ServiceForm',
+    'VLANForm',
+    'VLANGroupForm',
+    'VRFForm',
+)
+
+
+class VRFForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
+    import_targets = DynamicModelMultipleChoiceField(
+        queryset=RouteTarget.objects.all(),
+        required=False
+    )
+    export_targets = DynamicModelMultipleChoiceField(
+        queryset=RouteTarget.objects.all(),
+        required=False
+    )
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
+        required=False
+    )
+
+    class Meta:
+        model = VRF
+        fields = [
+            'name', 'rd', 'enforce_unique', 'description', 'import_targets', 'export_targets', 'tenant_group', 'tenant',
+            'tags',
+        ]
+        fieldsets = (
+            ('VRF', ('name', 'rd', 'enforce_unique', 'description', 'tags')),
+            ('Route Targets', ('import_targets', 'export_targets')),
+            ('Tenancy', ('tenant_group', 'tenant')),
+        )
+        labels = {
+            'rd': "RD",
+        }
+        help_texts = {
+            'rd': "Route distinguisher in any format",
+        }
+
+
+class RouteTargetForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
+        required=False
+    )
+
+    class Meta:
+        model = RouteTarget
+        fields = [
+            'name', 'description', 'tenant_group', 'tenant', 'tags',
+        ]
+        fieldsets = (
+            ('Route Target', ('name', 'description', 'tags')),
+            ('Tenancy', ('tenant_group', 'tenant')),
+        )
+
+
+class RIRForm(BootstrapMixin, CustomFieldModelForm):
+    slug = SlugField()
+
+    class Meta:
+        model = RIR
+        fields = [
+            'name', 'slug', 'is_private', 'description',
+        ]
+
+
+class AggregateForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
+    rir = DynamicModelChoiceField(
+        queryset=RIR.objects.all(),
+        label='RIR'
+    )
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
+        required=False
+    )
+
+    class Meta:
+        model = Aggregate
+        fields = [
+            'prefix', 'rir', 'date_added', 'description', 'tenant_group', 'tenant', 'tags',
+        ]
+        fieldsets = (
+            ('Aggregate', ('prefix', 'rir', 'date_added', 'description', 'tags')),
+            ('Tenancy', ('tenant_group', 'tenant')),
+        )
+        help_texts = {
+            'prefix': "IPv4 or IPv6 network",
+            'rir': "Regional Internet Registry responsible for this prefix",
+        }
+        widgets = {
+            'date_added': DatePicker(),
+        }
+
+
+class RoleForm(BootstrapMixin, CustomFieldModelForm):
+    slug = SlugField()
+
+    class Meta:
+        model = Role
+        fields = [
+            'name', 'slug', 'weight', 'description',
+        ]
+
+
+class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
+    vrf = DynamicModelChoiceField(
+        queryset=VRF.objects.all(),
+        required=False,
+        label='VRF'
+    )
+    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,
+        null_option='None',
+        query_params={
+            'region_id': '$region',
+            'group_id': '$site_group',
+        }
+    )
+    vlan_group = DynamicModelChoiceField(
+        queryset=VLANGroup.objects.all(),
+        required=False,
+        label='VLAN group',
+        null_option='None',
+        query_params={
+            'site_id': '$site'
+        },
+        initial_params={
+            'vlans': '$vlan'
+        }
+    )
+    vlan = DynamicModelChoiceField(
+        queryset=VLAN.objects.all(),
+        required=False,
+        label='VLAN',
+        query_params={
+            'site_id': '$site',
+            'group_id': '$vlan_group',
+        }
+    )
+    role = DynamicModelChoiceField(
+        queryset=Role.objects.all(),
+        required=False
+    )
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
+        required=False
+    )
+
+    class Meta:
+        model = Prefix
+        fields = [
+            'prefix', 'vrf', 'site', 'vlan', 'status', 'role', 'is_pool', 'mark_utilized', 'description',
+            'tenant_group', 'tenant', 'tags',
+        ]
+        fieldsets = (
+            ('Prefix', ('prefix', 'status', 'vrf', 'role', 'is_pool', 'mark_utilized', 'description', 'tags')),
+            ('Site/VLAN Assignment', ('region', 'site_group', 'site', 'vlan_group', 'vlan')),
+            ('Tenancy', ('tenant_group', 'tenant')),
+        )
+        widgets = {
+            'status': StaticSelect(),
+        }
+
+
+class IPRangeForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
+    vrf = DynamicModelChoiceField(
+        queryset=VRF.objects.all(),
+        required=False,
+        label='VRF'
+    )
+    role = DynamicModelChoiceField(
+        queryset=Role.objects.all(),
+        required=False
+    )
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
+        required=False
+    )
+
+    class Meta:
+        model = IPRange
+        fields = [
+            'vrf', 'start_address', 'end_address', 'status', 'role', 'description', 'tenant_group', 'tenant', 'tags',
+        ]
+        fieldsets = (
+            ('IP Range', ('vrf', 'start_address', 'end_address', 'role', 'status', 'description', 'tags')),
+            ('Tenancy', ('tenant_group', 'tenant')),
+        )
+        widgets = {
+            'status': StaticSelect(),
+        }
+
+
+class IPAddressForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
+    device = DynamicModelChoiceField(
+        queryset=Device.objects.all(),
+        required=False,
+        initial_params={
+            'interfaces': '$interface'
+        }
+    )
+    interface = DynamicModelChoiceField(
+        queryset=Interface.objects.all(),
+        required=False,
+        query_params={
+            'device_id': '$device'
+        }
+    )
+    virtual_machine = DynamicModelChoiceField(
+        queryset=VirtualMachine.objects.all(),
+        required=False,
+        initial_params={
+            'interfaces': '$vminterface'
+        }
+    )
+    vminterface = DynamicModelChoiceField(
+        queryset=VMInterface.objects.all(),
+        required=False,
+        label='Interface',
+        query_params={
+            'virtual_machine_id': '$virtual_machine'
+        }
+    )
+    vrf = DynamicModelChoiceField(
+        queryset=VRF.objects.all(),
+        required=False,
+        label='VRF'
+    )
+    nat_region = DynamicModelChoiceField(
+        queryset=Region.objects.all(),
+        required=False,
+        label='Region',
+        initial_params={
+            'sites': '$nat_site'
+        }
+    )
+    nat_site_group = DynamicModelChoiceField(
+        queryset=SiteGroup.objects.all(),
+        required=False,
+        label='Site group',
+        initial_params={
+            'sites': '$nat_site'
+        }
+    )
+    nat_site = DynamicModelChoiceField(
+        queryset=Site.objects.all(),
+        required=False,
+        label='Site',
+        query_params={
+            'region_id': '$nat_region',
+            'group_id': '$nat_site_group',
+        }
+    )
+    nat_rack = DynamicModelChoiceField(
+        queryset=Rack.objects.all(),
+        required=False,
+        label='Rack',
+        null_option='None',
+        query_params={
+            'site_id': '$site'
+        }
+    )
+    nat_device = DynamicModelChoiceField(
+        queryset=Device.objects.all(),
+        required=False,
+        label='Device',
+        query_params={
+            'site_id': '$site',
+            'rack_id': '$nat_rack',
+        }
+    )
+    nat_cluster = DynamicModelChoiceField(
+        queryset=Cluster.objects.all(),
+        required=False,
+        label='Cluster'
+    )
+    nat_virtual_machine = DynamicModelChoiceField(
+        queryset=VirtualMachine.objects.all(),
+        required=False,
+        label='Virtual Machine',
+        query_params={
+            'cluster_id': '$nat_cluster',
+        }
+    )
+    nat_vrf = DynamicModelChoiceField(
+        queryset=VRF.objects.all(),
+        required=False,
+        label='VRF'
+    )
+    nat_inside = DynamicModelChoiceField(
+        queryset=IPAddress.objects.all(),
+        required=False,
+        label='IP Address',
+        query_params={
+            'device_id': '$nat_device',
+            'virtual_machine_id': '$nat_virtual_machine',
+            'vrf_id': '$nat_vrf',
+        }
+    )
+    primary_for_parent = forms.BooleanField(
+        required=False,
+        label='Make this the primary IP for the device/VM'
+    )
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
+        required=False
+    )
+
+    class Meta:
+        model = IPAddress
+        fields = [
+            'address', 'vrf', 'status', 'role', 'dns_name', 'description', 'primary_for_parent', 'nat_site', 'nat_rack',
+            'nat_device', 'nat_cluster', 'nat_virtual_machine', 'nat_vrf', 'nat_inside', 'tenant_group', 'tenant',
+            'tags',
+        ]
+        widgets = {
+            'status': StaticSelect(),
+            'role': StaticSelect(),
+        }
+
+    def __init__(self, *args, **kwargs):
+
+        # Initialize helper selectors
+        instance = kwargs.get('instance')
+        initial = kwargs.get('initial', {}).copy()
+        if instance:
+            if type(instance.assigned_object) is Interface:
+                initial['interface'] = instance.assigned_object
+            elif type(instance.assigned_object) is VMInterface:
+                initial['vminterface'] = instance.assigned_object
+            if instance.nat_inside:
+                nat_inside_parent = instance.nat_inside.assigned_object
+                if type(nat_inside_parent) is Interface:
+                    initial['nat_site'] = nat_inside_parent.device.site.pk
+                    if nat_inside_parent.device.rack:
+                        initial['nat_rack'] = nat_inside_parent.device.rack.pk
+                    initial['nat_device'] = nat_inside_parent.device.pk
+                elif type(nat_inside_parent) is VMInterface:
+                    initial['nat_cluster'] = nat_inside_parent.virtual_machine.cluster.pk
+                    initial['nat_virtual_machine'] = nat_inside_parent.virtual_machine.pk
+        kwargs['initial'] = initial
+
+        super().__init__(*args, **kwargs)
+
+        # Initialize primary_for_parent if IP address is already assigned
+        if self.instance.pk and self.instance.assigned_object:
+            parent = self.instance.assigned_object.parent_object
+            if (
+                self.instance.address.version == 4 and parent.primary_ip4_id == self.instance.pk or
+                self.instance.address.version == 6 and parent.primary_ip6_id == self.instance.pk
+            ):
+                self.initial['primary_for_parent'] = True
+
+    def clean(self):
+        super().clean()
+
+        # Cannot select both a device interface and a VM interface
+        if self.cleaned_data.get('interface') and self.cleaned_data.get('vminterface'):
+            raise forms.ValidationError("Cannot select both a device interface and a virtual machine interface")
+        self.instance.assigned_object = self.cleaned_data.get('interface') or self.cleaned_data.get('vminterface')
+
+        # Primary IP assignment is only available if an interface has been assigned.
+        interface = self.cleaned_data.get('interface') or self.cleaned_data.get('vminterface')
+        if self.cleaned_data.get('primary_for_parent') and not interface:
+            self.add_error(
+                'primary_for_parent', "Only IP addresses assigned to an interface can be designated as primary IPs."
+            )
+
+    def save(self, *args, **kwargs):
+        ipaddress = super().save(*args, **kwargs)
+
+        # Assign/clear this IPAddress as the primary for the associated Device/VirtualMachine.
+        interface = self.instance.assigned_object
+        if interface:
+            parent = interface.parent_object
+            if self.cleaned_data['primary_for_parent']:
+                if ipaddress.address.version == 4:
+                    parent.primary_ip4 = ipaddress
+                else:
+                    parent.primary_ip6 = ipaddress
+                parent.save()
+            elif ipaddress.address.version == 4 and parent.primary_ip4 == ipaddress:
+                parent.primary_ip4 = None
+                parent.save()
+            elif ipaddress.address.version == 6 and parent.primary_ip6 == ipaddress:
+                parent.primary_ip6 = None
+                parent.save()
+
+        return ipaddress
+
+
+class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
+    vrf = DynamicModelChoiceField(
+        queryset=VRF.objects.all(),
+        required=False,
+        label='VRF'
+    )
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
+        required=False
+    )
+
+    class Meta:
+        model = IPAddress
+        fields = [
+            'address', 'vrf', 'status', 'role', 'dns_name', 'description', 'tenant_group', 'tenant', 'tags',
+        ]
+        widgets = {
+            'status': StaticSelect(),
+            'role': StaticSelect(),
+        }
+
+
+class IPAddressAssignForm(BootstrapMixin, forms.Form):
+    vrf_id = DynamicModelChoiceField(
+        queryset=VRF.objects.all(),
+        required=False,
+        label='VRF'
+    )
+    q = forms.CharField(
+        required=False,
+        label='Search',
+    )
+
+
+class VLANGroupForm(BootstrapMixin, CustomFieldModelForm):
+    scope_type = ContentTypeChoiceField(
+        queryset=ContentType.objects.filter(model__in=VLANGROUP_SCOPE_TYPES),
+        required=False,
+        widget=StaticSelect
+    )
+    region = DynamicModelChoiceField(
+        queryset=Region.objects.all(),
+        required=False,
+        initial_params={
+            'sites': '$site'
+        }
+    )
+    sitegroup = DynamicModelChoiceField(
+        queryset=SiteGroup.objects.all(),
+        required=False,
+        initial_params={
+            'sites': '$site'
+        },
+        label='Site group'
+    )
+    site = DynamicModelChoiceField(
+        queryset=Site.objects.all(),
+        required=False,
+        initial_params={
+            'locations': '$location'
+        },
+        query_params={
+            'region_id': '$region',
+            'group_id': '$sitegroup',
+        }
+    )
+    location = DynamicModelChoiceField(
+        queryset=Location.objects.all(),
+        required=False,
+        initial_params={
+            'racks': '$rack'
+        },
+        query_params={
+            'site_id': '$site',
+        }
+    )
+    rack = DynamicModelChoiceField(
+        queryset=Rack.objects.all(),
+        required=False,
+        query_params={
+            'site_id': '$site',
+            'location_id': '$location',
+        }
+    )
+    clustergroup = DynamicModelChoiceField(
+        queryset=ClusterGroup.objects.all(),
+        required=False,
+        initial_params={
+            'clusters': '$cluster'
+        },
+        label='Cluster group'
+    )
+    cluster = DynamicModelChoiceField(
+        queryset=Cluster.objects.all(),
+        required=False,
+        query_params={
+            'group_id': '$clustergroup',
+        }
+    )
+    slug = SlugField()
+
+    class Meta:
+        model = VLANGroup
+        fields = [
+            'name', 'slug', 'description', 'scope_type', 'region', 'sitegroup', 'site', 'location', 'rack',
+            'clustergroup', 'cluster',
+        ]
+        fieldsets = (
+            ('VLAN Group', ('name', 'slug', 'description')),
+            ('Scope', ('scope_type', 'region', 'sitegroup', 'site', 'location', 'rack', 'clustergroup', 'cluster')),
+        )
+        widgets = {
+            'scope_type': StaticSelect,
+        }
+
+    def __init__(self, *args, **kwargs):
+        instance = kwargs.get('instance')
+        initial = kwargs.get('initial', {})
+
+        if instance is not None and instance.scope:
+            initial[instance.scope_type.model] = instance.scope
+
+            kwargs['initial'] = initial
+
+        super().__init__(*args, **kwargs)
+
+    def clean(self):
+        super().clean()
+
+        # Assign scope based on scope_type
+        if self.cleaned_data.get('scope_type'):
+            scope_field = self.cleaned_data['scope_type'].model
+            self.instance.scope = self.cleaned_data.get(scope_field)
+        else:
+            self.instance.scope_id = None
+
+
+class VLANForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
+    # VLANGroup assignment fields
+    scope_type = forms.ChoiceField(
+        choices=(
+            ('', ''),
+            ('dcim.region', 'Region'),
+            ('dcim.sitegroup', 'Site group'),
+            ('dcim.site', 'Site'),
+            ('dcim.location', 'Location'),
+            ('dcim.rack', 'Rack'),
+            ('virtualization.clustergroup', 'Cluster group'),
+            ('virtualization.cluster', 'Cluster'),
+        ),
+        required=False,
+        widget=StaticSelect,
+        label='Group scope'
+    )
+    group = DynamicModelChoiceField(
+        queryset=VLANGroup.objects.all(),
+        required=False,
+        query_params={
+            'scope_type': '$scope_type',
+        },
+        label='VLAN Group'
+    )
+
+    # Site assignment fields
+    region = DynamicModelChoiceField(
+        queryset=Region.objects.all(),
+        required=False,
+        initial_params={
+            'sites': '$site'
+        },
+        label='Region'
+    )
+    sitegroup = DynamicModelChoiceField(
+        queryset=SiteGroup.objects.all(),
+        required=False,
+        initial_params={
+            'sites': '$site'
+        },
+        label='Site group'
+    )
+    site = DynamicModelChoiceField(
+        queryset=Site.objects.all(),
+        required=False,
+        null_option='None',
+        query_params={
+            'region_id': '$region',
+            'group_id': '$sitegroup',
+        }
+    )
+
+    # Other fields
+    role = DynamicModelChoiceField(
+        queryset=Role.objects.all(),
+        required=False
+    )
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
+        required=False
+    )
+
+    class Meta:
+        model = VLAN
+        fields = [
+            'site', 'group', 'vid', 'name', 'status', 'role', 'description', 'tenant_group', 'tenant', 'tags',
+        ]
+        help_texts = {
+            'site': "Leave blank if this VLAN spans multiple sites",
+            'group': "VLAN group (optional)",
+            'vid': "Configured VLAN ID",
+            'name': "Configured VLAN name",
+            'status': "Operational status of this VLAN",
+            'role': "The primary function of this VLAN",
+        }
+        widgets = {
+            'status': StaticSelect(),
+        }
+
+
+class ServiceForm(BootstrapMixin, CustomFieldModelForm):
+    ports = NumericArrayField(
+        base_field=forms.IntegerField(
+            min_value=SERVICE_PORT_MIN,
+            max_value=SERVICE_PORT_MAX
+        ),
+        help_text="Comma-separated list of one or more port numbers. A range may be specified using a hyphen."
+    )
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
+        required=False
+    )
+
+    class Meta:
+        model = Service
+        fields = [
+            'name', 'protocol', 'ports', 'ipaddresses', 'description', 'tags',
+        ]
+        help_texts = {
+            'ipaddresses': "IP address assignment is optional. If no IPs are selected, the service is assumed to be "
+                           "reachable via all IPs assigned to the device.",
+        }
+        widgets = {
+            'protocol': StaticSelect(),
+            'ipaddresses': StaticSelectMultiple(),
+        }
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        # Limit IP address choices to those assigned to interfaces of the parent device/VM
+        if self.instance.device:
+            self.fields['ipaddresses'].queryset = IPAddress.objects.filter(
+                interface__in=self.instance.device.vc_interfaces().values_list('id', flat=True)
+            )
+        elif self.instance.virtual_machine:
+            self.fields['ipaddresses'].queryset = IPAddress.objects.filter(
+                vminterface__in=self.instance.virtual_machine.interfaces.values_list('id', flat=True)
+            )
+        else:
+            self.fields['ipaddresses'].choices = []

+ 0 - 196
netbox/tenancy/forms.py

@@ -1,196 +0,0 @@
-from django import forms
-from django.utils.translation import gettext as _
-
-from extras.forms import (
-    AddRemoveTagsForm, CustomFieldModelForm, CustomFieldModelBulkEditForm, CustomFieldModelFilterForm, CustomFieldModelCSVForm,
-)
-from extras.models import Tag
-from utilities.forms import (
-    BootstrapMixin, CommentField, CSVModelChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
-    SlugField, TagFilterField,
-)
-from .models import Tenant, TenantGroup
-
-
-#
-# Tenant groups
-#
-
-class TenantGroupForm(BootstrapMixin, CustomFieldModelForm):
-    parent = DynamicModelChoiceField(
-        queryset=TenantGroup.objects.all(),
-        required=False
-    )
-    slug = SlugField()
-
-    class Meta:
-        model = TenantGroup
-        fields = [
-            'parent', 'name', 'slug', 'description',
-        ]
-
-
-class TenantGroupCSVForm(CustomFieldModelCSVForm):
-    parent = CSVModelChoiceField(
-        queryset=TenantGroup.objects.all(),
-        required=False,
-        to_field_name='name',
-        help_text='Parent group'
-    )
-    slug = SlugField()
-
-    class Meta:
-        model = TenantGroup
-        fields = ('name', 'slug', 'parent', 'description')
-
-
-class TenantGroupBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=TenantGroup.objects.all(),
-        widget=forms.MultipleHiddenInput
-    )
-    parent = DynamicModelChoiceField(
-        queryset=TenantGroup.objects.all(),
-        required=False
-    )
-    description = forms.CharField(
-        max_length=200,
-        required=False
-    )
-
-    class Meta:
-        nullable_fields = ['parent', 'description']
-
-
-class TenantGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
-    model = TenantGroup
-    q = forms.CharField(
-        required=False,
-        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
-        label=_('Search')
-    )
-    parent_id = DynamicModelMultipleChoiceField(
-        queryset=TenantGroup.objects.all(),
-        required=False,
-        label=_('Parent group'),
-        fetch_trigger='open'
-    )
-
-
-#
-# Tenants
-#
-
-class TenantForm(BootstrapMixin, CustomFieldModelForm):
-    slug = SlugField()
-    group = DynamicModelChoiceField(
-        queryset=TenantGroup.objects.all(),
-        required=False
-    )
-    comments = CommentField()
-    tags = DynamicModelMultipleChoiceField(
-        queryset=Tag.objects.all(),
-        required=False
-    )
-
-    class Meta:
-        model = Tenant
-        fields = (
-            'name', 'slug', 'group', 'description', 'comments', 'tags',
-        )
-        fieldsets = (
-            ('Tenant', ('name', 'slug', 'group', 'description', 'tags')),
-        )
-
-
-class TenantCSVForm(CustomFieldModelCSVForm):
-    slug = SlugField()
-    group = CSVModelChoiceField(
-        queryset=TenantGroup.objects.all(),
-        required=False,
-        to_field_name='name',
-        help_text='Assigned group'
-    )
-
-    class Meta:
-        model = Tenant
-        fields = ('name', 'slug', 'group', 'description', 'comments')
-
-
-class TenantBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=Tenant.objects.all(),
-        widget=forms.MultipleHiddenInput()
-    )
-    group = DynamicModelChoiceField(
-        queryset=TenantGroup.objects.all(),
-        required=False
-    )
-
-    class Meta:
-        nullable_fields = [
-            'group',
-        ]
-
-
-class TenantFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
-    model = Tenant
-    field_groups = (
-        ('q', 'tag'),
-        ('group_id',),
-    )
-    q = forms.CharField(
-        required=False,
-        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
-        label=_('Search')
-    )
-    group_id = DynamicModelMultipleChoiceField(
-        queryset=TenantGroup.objects.all(),
-        required=False,
-        null_option='None',
-        label=_('Group'),
-        fetch_trigger='open'
-    )
-    tag = TagFilterField(model)
-
-
-#
-# Form extensions
-#
-
-class TenancyForm(forms.Form):
-    tenant_group = DynamicModelChoiceField(
-        queryset=TenantGroup.objects.all(),
-        required=False,
-        null_option='None',
-        initial_params={
-            'tenants': '$tenant'
-        }
-    )
-    tenant = DynamicModelChoiceField(
-        queryset=Tenant.objects.all(),
-        required=False,
-        query_params={
-            'group_id': '$tenant_group'
-        }
-    )
-
-
-class TenancyFilterForm(forms.Form):
-    tenant_group_id = DynamicModelMultipleChoiceField(
-        queryset=TenantGroup.objects.all(),
-        required=False,
-        null_option='None',
-        label=_('Tenant group'),
-        fetch_trigger='open'
-    )
-    tenant_id = DynamicModelMultipleChoiceField(
-        queryset=Tenant.objects.all(),
-        required=False,
-        null_option='None',
-        query_params={
-            'group_id': '$tenant_group_id'
-        },
-        label=_('Tenant'),
-        fetch_trigger='open'
-    )

+ 5 - 0
netbox/tenancy/forms/__init__.py

@@ -0,0 +1,5 @@
+from .forms import *
+from .models import *
+from .filtersets import *
+from .bulk_edit import *
+from .bulk_import import *

+ 44 - 0
netbox/tenancy/forms/bulk_edit.py

@@ -0,0 +1,44 @@
+from django import forms
+
+from extras.forms import AddRemoveTagsForm, CustomFieldModelBulkEditForm
+from tenancy.models import Tenant, TenantGroup
+from utilities.forms import BootstrapMixin, DynamicModelChoiceField
+
+__all__ = (
+    'TenantBulkEditForm',
+    'TenantGroupBulkEditForm',
+)
+
+
+class TenantGroupBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=TenantGroup.objects.all(),
+        widget=forms.MultipleHiddenInput
+    )
+    parent = DynamicModelChoiceField(
+        queryset=TenantGroup.objects.all(),
+        required=False
+    )
+    description = forms.CharField(
+        max_length=200,
+        required=False
+    )
+
+    class Meta:
+        nullable_fields = ['parent', 'description']
+
+
+class TenantBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=Tenant.objects.all(),
+        widget=forms.MultipleHiddenInput()
+    )
+    group = DynamicModelChoiceField(
+        queryset=TenantGroup.objects.all(),
+        required=False
+    )
+
+    class Meta:
+        nullable_fields = [
+            'group',
+        ]

+ 36 - 0
netbox/tenancy/forms/bulk_import.py

@@ -0,0 +1,36 @@
+from extras.forms import CustomFieldModelCSVForm
+from tenancy.models import Tenant, TenantGroup
+from utilities.forms import CSVModelChoiceField, SlugField
+
+__all__ = (
+    'TenantCSVForm',
+    'TenantGroupCSVForm',
+)
+
+
+class TenantGroupCSVForm(CustomFieldModelCSVForm):
+    parent = CSVModelChoiceField(
+        queryset=TenantGroup.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text='Parent group'
+    )
+    slug = SlugField()
+
+    class Meta:
+        model = TenantGroup
+        fields = ('name', 'slug', 'parent', 'description')
+
+
+class TenantCSVForm(CustomFieldModelCSVForm):
+    slug = SlugField()
+    group = CSVModelChoiceField(
+        queryset=TenantGroup.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text='Assigned group'
+    )
+
+    class Meta:
+        model = Tenant
+        fields = ('name', 'slug', 'group', 'description', 'comments')

+ 42 - 0
netbox/tenancy/forms/filtersets.py

@@ -0,0 +1,42 @@
+from django import forms
+from django.utils.translation import gettext as _
+
+from extras.forms import CustomFieldModelFilterForm
+from tenancy.models import Tenant, TenantGroup
+from utilities.forms import BootstrapMixin, DynamicModelMultipleChoiceField, TagFilterField
+
+
+class TenantGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
+    model = TenantGroup
+    q = forms.CharField(
+        required=False,
+        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
+        label=_('Search')
+    )
+    parent_id = DynamicModelMultipleChoiceField(
+        queryset=TenantGroup.objects.all(),
+        required=False,
+        label=_('Parent group'),
+        fetch_trigger='open'
+    )
+
+
+class TenantFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
+    model = Tenant
+    field_groups = (
+        ('q', 'tag'),
+        ('group_id',),
+    )
+    q = forms.CharField(
+        required=False,
+        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
+        label=_('Search')
+    )
+    group_id = DynamicModelMultipleChoiceField(
+        queryset=TenantGroup.objects.all(),
+        required=False,
+        null_option='None',
+        label=_('Group'),
+        fetch_trigger='open'
+    )
+    tag = TagFilterField(model)

+ 48 - 0
netbox/tenancy/forms/forms.py

@@ -0,0 +1,48 @@
+from django import forms
+from django.utils.translation import gettext as _
+
+from tenancy.models import Tenant, TenantGroup
+from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField
+
+__all__ = (
+    'TenancyForm',
+    'TenancyFilterForm',
+)
+
+
+class TenancyForm(forms.Form):
+    tenant_group = DynamicModelChoiceField(
+        queryset=TenantGroup.objects.all(),
+        required=False,
+        null_option='None',
+        initial_params={
+            'tenants': '$tenant'
+        }
+    )
+    tenant = DynamicModelChoiceField(
+        queryset=Tenant.objects.all(),
+        required=False,
+        query_params={
+            'group_id': '$tenant_group'
+        }
+    )
+
+
+class TenancyFilterForm(forms.Form):
+    tenant_group_id = DynamicModelMultipleChoiceField(
+        queryset=TenantGroup.objects.all(),
+        required=False,
+        null_option='None',
+        label=_('Tenant group'),
+        fetch_trigger='open'
+    )
+    tenant_id = DynamicModelMultipleChoiceField(
+        queryset=Tenant.objects.all(),
+        required=False,
+        null_option='None',
+        query_params={
+            'group_id': '$tenant_group_id'
+        },
+        label=_('Tenant'),
+        fetch_trigger='open'
+    )

+ 47 - 0
netbox/tenancy/forms/models.py

@@ -0,0 +1,47 @@
+from extras.forms import CustomFieldModelForm
+from extras.models import Tag
+from tenancy.models import Tenant, TenantGroup
+from utilities.forms import (
+    BootstrapMixin, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField,
+)
+
+__all__ = (
+    'TenantForm',
+    'TenantGroupForm',
+)
+
+
+class TenantGroupForm(BootstrapMixin, CustomFieldModelForm):
+    parent = DynamicModelChoiceField(
+        queryset=TenantGroup.objects.all(),
+        required=False
+    )
+    slug = SlugField()
+
+    class Meta:
+        model = TenantGroup
+        fields = [
+            'parent', 'name', 'slug', 'description',
+        ]
+
+
+class TenantForm(BootstrapMixin, CustomFieldModelForm):
+    slug = SlugField()
+    group = DynamicModelChoiceField(
+        queryset=TenantGroup.objects.all(),
+        required=False
+    )
+    comments = CommentField()
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
+        required=False
+    )
+
+    class Meta:
+        model = Tenant
+        fields = (
+            'name', 'slug', 'group', 'description', 'comments', 'tags',
+        )
+        fieldsets = (
+            ('Tenant', ('name', 'slug', 'group', 'description', 'tags')),
+        )

+ 0 - 965
netbox/virtualization/forms.py

@@ -1,965 +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 import InterfaceCommonForm, INTERFACE_MODE_HELP_TEXT
-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)