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

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 dcim.choices import DeviceFaceChoices, DeviceStatusChoices, InterfaceTypeChoices
 from dcim.forms import *
 from dcim.models import *
 from virtualization.models import Cluster, ClusterGroup, ClusterType

+ 1 - 2
netbox/dcim/views.py

@@ -1,11 +1,10 @@
-import logging
 from collections import OrderedDict
 
 from django.contrib import messages
 from django.contrib.contenttypes.models import ContentType
 from django.core.paginator import EmptyPage, PageNotAnInteger
 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.shortcuts import get_object_or_404, redirect, render
 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)