Procházet zdrojové kódy

Merge pull request #8504 from netbox-community/8488-plugins-forms

Closes #8488: Support form components for plugins
Jeremy Stretch před 4 roky
rodič
revize
4347f624d8
47 změnil soubory, kde provedl 1625 přidání a 1567 odebrání
  1. 78 0
      docs/plugins/development/forms.md
  2. 1 0
      mkdocs.yml
  3. 15 19
      netbox/circuits/forms/bulk_edit.py
  4. 5 5
      netbox/circuits/forms/bulk_import.py
  5. 20 20
      netbox/circuits/forms/filtersets.py
  6. 19 16
      netbox/circuits/forms/models.py
  7. 79 132
      netbox/dcim/forms/bulk_edit.py
  8. 28 28
      netbox/dcim/forms/bulk_import.py
  9. 4 4
      netbox/dcim/forms/connections.py
  10. 147 143
      netbox/dcim/forms/filtersets.py
  11. 91 83
      netbox/dcim/forms/models.py
  12. 2 2
      netbox/dcim/forms/object_create.py
  13. 5 18
      netbox/extras/forms/bulk_edit.py
  14. 0 81
      netbox/extras/forms/customfields.py
  15. 34 35
      netbox/extras/forms/filtersets.py
  16. 33 45
      netbox/extras/forms/models.py
  17. 35 64
      netbox/ipam/forms/bulk_edit.py
  18. 15 15
      netbox/ipam/forms/bulk_import.py
  19. 71 71
      netbox/ipam/forms/filtersets.py
  20. 60 52
      netbox/ipam/forms/models.py
  21. 1 0
      netbox/netbox/forms/__init__.py
  22. 126 0
      netbox/netbox/forms/base.py
  23. 3 7
      netbox/netbox/views/generic/bulk_views.py
  24. 2 2
      netbox/templates/generic/object_edit.html
  25. 16 15
      netbox/templates/inc/filter_list.html
  26. 1 1
      netbox/templates/users/preferences.html
  27. 11 18
      netbox/tenancy/forms/bulk_edit.py
  28. 6 6
      netbox/tenancy/forms/bulk_import.py
  29. 6 14
      netbox/tenancy/forms/filtersets.py
  30. 14 12
      netbox/tenancy/forms/models.py
  31. 10 10
      netbox/users/forms.py
  32. 0 526
      netbox/utilities/forms/fields.py
  33. 5 0
      netbox/utilities/forms/fields/__init__.py
  34. 37 0
      netbox/utilities/forms/fields/content_types.py
  35. 193 0
      netbox/utilities/forms/fields/csv.py
  36. 141 0
      netbox/utilities/forms/fields/dynamic.py
  37. 54 0
      netbox/utilities/forms/fields/expandable.py
  38. 127 0
      netbox/utilities/forms/fields/fields.py
  39. 21 16
      netbox/utilities/forms/forms.py
  40. 17 22
      netbox/virtualization/forms/bulk_edit.py
  41. 6 6
      netbox/virtualization/forms/bulk_import.py
  42. 25 24
      netbox/virtualization/forms/filtersets.py
  43. 20 18
      netbox/virtualization/forms/models.py
  44. 11 10
      netbox/wireless/forms/bulk_edit.py
  45. 4 4
      netbox/wireless/forms/bulk_import.py
  46. 9 8
      netbox/wireless/forms/filtersets.py
  47. 17 15
      netbox/wireless/forms/models.py

+ 78 - 0
docs/plugins/development/forms.md

@@ -0,0 +1,78 @@
+# Forms
+
+## Form Classes
+
+NetBox provides several base form classes for use by plugins. These are documented below.
+
+* `NetBoxModelForm`
+* `NetBoxModelCSVForm`
+* `NetBoxModelBulkEditForm`
+* `NetBoxModelFilterSetForm`
+
+### TODO: Include forms reference
+
+In addition to the [form fields provided by Django](https://docs.djangoproject.com/en/stable/ref/forms/fields/), NetBox provides several field classes for use within forms to handle specific types of data. These can be imported from `utilities.forms.fields` and are documented below.
+
+## General Purpose Fields
+
+::: utilities.forms.ColorField
+    selection:
+      members: false
+
+::: utilities.forms.CommentField
+    selection:
+      members: false
+
+::: utilities.forms.JSONField
+    selection:
+      members: false
+
+::: utilities.forms.MACAddressField
+    selection:
+      members: false
+
+::: utilities.forms.SlugField
+    selection:
+      members: false
+
+## Dynamic Object Fields
+
+::: utilities.forms.DynamicModelChoiceField
+    selection:
+      members: false
+
+::: utilities.forms.DynamicModelMultipleChoiceField
+    selection:
+      members: false
+
+## Content Type Fields
+
+::: utilities.forms.ContentTypeChoiceField
+    selection:
+      members: false
+
+::: utilities.forms.ContentTypeMultipleChoiceField
+    selection:
+      members: false
+
+## CSV Import Fields
+
+::: utilities.forms.CSVChoiceField
+    selection:
+      members: false
+
+::: utilities.forms.CSVMultipleChoiceField
+    selection:
+      members: false
+
+::: utilities.forms.CSVModelChoiceField
+    selection:
+      members: false
+
+::: utilities.forms.CSVContentTypeField
+    selection:
+      members: false
+
+::: utilities.forms.CSVMultipleContentTypeField
+    selection:
+      members: false

+ 1 - 0
mkdocs.yml

@@ -105,6 +105,7 @@ nav:
             - Models: 'plugins/development/models.md'
             - Models: 'plugins/development/models.md'
             - Views: 'plugins/development/views.md'
             - Views: 'plugins/development/views.md'
             - Tables: 'plugins/development/tables.md'
             - Tables: 'plugins/development/tables.md'
+            - Forms: 'plugins/development/forms.md'
             - Filter Sets: 'plugins/development/filtersets.md'
             - Filter Sets: 'plugins/development/filtersets.md'
             - REST API: 'plugins/development/rest-api.md'
             - REST API: 'plugins/development/rest-api.md'
             - Background Tasks: 'plugins/development/background-tasks.md'
             - Background Tasks: 'plugins/development/background-tasks.md'

+ 15 - 19
netbox/circuits/forms/bulk_edit.py

@@ -2,7 +2,7 @@ from django import forms
 
 
 from circuits.choices import CircuitStatusChoices
 from circuits.choices import CircuitStatusChoices
 from circuits.models import *
 from circuits.models import *
-from extras.forms import AddRemoveTagsForm, CustomFieldModelBulkEditForm
+from netbox.forms import NetBoxModelBulkEditForm
 from tenancy.models import Tenant
 from tenancy.models import Tenant
 from utilities.forms import add_blank_choice, CommentField, DynamicModelChoiceField, SmallTextarea, StaticSelect
 from utilities.forms import add_blank_choice, CommentField, DynamicModelChoiceField, SmallTextarea, StaticSelect
 
 
@@ -14,7 +14,7 @@ __all__ = (
 )
 )
 
 
 
 
-class ProviderBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class ProviderBulkEditForm(NetBoxModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=Provider.objects.all(),
         queryset=Provider.objects.all(),
         widget=forms.MultipleHiddenInput
         widget=forms.MultipleHiddenInput
@@ -47,13 +47,12 @@ class ProviderBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
         label='Comments'
         label='Comments'
     )
     )
 
 
-    class Meta:
-        nullable_fields = [
-            'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments',
-        ]
+    nullable_fields = (
+        'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments',
+    )
 
 
 
 
-class ProviderNetworkBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class ProviderNetworkBulkEditForm(NetBoxModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=ProviderNetwork.objects.all(),
         queryset=ProviderNetwork.objects.all(),
         widget=forms.MultipleHiddenInput
         widget=forms.MultipleHiddenInput
@@ -75,13 +74,12 @@ class ProviderNetworkBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditFor
         label='Comments'
         label='Comments'
     )
     )
 
 
-    class Meta:
-        nullable_fields = [
-            'service_id', 'description', 'comments',
-        ]
+    nullable_fields = (
+        'service_id', 'description', 'comments',
+    )
 
 
 
 
-class CircuitTypeBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class CircuitTypeBulkEditForm(NetBoxModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=CircuitType.objects.all(),
         queryset=CircuitType.objects.all(),
         widget=forms.MultipleHiddenInput
         widget=forms.MultipleHiddenInput
@@ -91,11 +89,10 @@ class CircuitTypeBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
         required=False
         required=False
     )
     )
 
 
-    class Meta:
-        nullable_fields = ['description']
+    nullable_fields = ('description',)
 
 
 
 
-class CircuitBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class CircuitBulkEditForm(NetBoxModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=Circuit.objects.all(),
         queryset=Circuit.objects.all(),
         widget=forms.MultipleHiddenInput
         widget=forms.MultipleHiddenInput
@@ -131,7 +128,6 @@ class CircuitBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
         label='Comments'
         label='Comments'
     )
     )
 
 
-    class Meta:
-        nullable_fields = [
-            'tenant', 'commit_rate', 'description', 'comments',
-        ]
+    nullable_fields = (
+        'tenant', 'commit_rate', 'description', 'comments',
+    )

+ 5 - 5
netbox/circuits/forms/bulk_import.py

@@ -1,6 +1,6 @@
 from circuits.choices import CircuitStatusChoices
 from circuits.choices import CircuitStatusChoices
 from circuits.models import *
 from circuits.models import *
-from extras.forms import CustomFieldModelCSVForm
+from netbox.forms import NetBoxModelCSVForm
 from tenancy.models import Tenant
 from tenancy.models import Tenant
 from utilities.forms import CSVChoiceField, CSVModelChoiceField, SlugField
 from utilities.forms import CSVChoiceField, CSVModelChoiceField, SlugField
 
 
@@ -12,7 +12,7 @@ __all__ = (
 )
 )
 
 
 
 
-class ProviderCSVForm(CustomFieldModelCSVForm):
+class ProviderCSVForm(NetBoxModelCSVForm):
     slug = SlugField()
     slug = SlugField()
 
 
     class Meta:
     class Meta:
@@ -22,7 +22,7 @@ class ProviderCSVForm(CustomFieldModelCSVForm):
         )
         )
 
 
 
 
-class ProviderNetworkCSVForm(CustomFieldModelCSVForm):
+class ProviderNetworkCSVForm(NetBoxModelCSVForm):
     provider = CSVModelChoiceField(
     provider = CSVModelChoiceField(
         queryset=Provider.objects.all(),
         queryset=Provider.objects.all(),
         to_field_name='name',
         to_field_name='name',
@@ -36,7 +36,7 @@ class ProviderNetworkCSVForm(CustomFieldModelCSVForm):
         ]
         ]
 
 
 
 
-class CircuitTypeCSVForm(CustomFieldModelCSVForm):
+class CircuitTypeCSVForm(NetBoxModelCSVForm):
     slug = SlugField()
     slug = SlugField()
 
 
     class Meta:
     class Meta:
@@ -47,7 +47,7 @@ class CircuitTypeCSVForm(CustomFieldModelCSVForm):
         }
         }
 
 
 
 
-class CircuitCSVForm(CustomFieldModelCSVForm):
+class CircuitCSVForm(NetBoxModelCSVForm):
     provider = CSVModelChoiceField(
     provider = CSVModelChoiceField(
         queryset=Provider.objects.all(),
         queryset=Provider.objects.all(),
         to_field_name='name',
         to_field_name='name',

+ 20 - 20
netbox/circuits/forms/filtersets.py

@@ -4,7 +4,7 @@ from django.utils.translation import gettext as _
 from circuits.choices import CircuitStatusChoices
 from circuits.choices import CircuitStatusChoices
 from circuits.models import *
 from circuits.models import *
 from dcim.models import Region, Site, SiteGroup
 from dcim.models import Region, Site, SiteGroup
-from extras.forms import CustomFieldModelFilterForm
+from netbox.forms import NetBoxModelFilterSetForm
 from tenancy.forms import TenancyFilterForm
 from tenancy.forms import TenancyFilterForm
 from utilities.forms import DynamicModelMultipleChoiceField, StaticSelectMultiple, TagFilterField
 from utilities.forms import DynamicModelMultipleChoiceField, StaticSelectMultiple, TagFilterField
 
 
@@ -16,13 +16,13 @@ __all__ = (
 )
 )
 
 
 
 
-class ProviderFilterForm(CustomFieldModelFilterForm):
+class ProviderFilterForm(NetBoxModelFilterSetForm):
     model = Provider
     model = Provider
-    field_groups = [
-        ['q', 'tag'],
-        ['region_id', 'site_group_id', 'site_id'],
-        ['asn'],
-    ]
+    fieldsets = (
+        (None, ('q', 'tag')),
+        ('Location', ('region_id', 'site_group_id', 'site_id')),
+        ('ASN', ('asn',)),
+    )
     region_id = DynamicModelMultipleChoiceField(
     region_id = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
         required=False,
         required=False,
@@ -49,11 +49,11 @@ class ProviderFilterForm(CustomFieldModelFilterForm):
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
-class ProviderNetworkFilterForm(CustomFieldModelFilterForm):
+class ProviderNetworkFilterForm(NetBoxModelFilterSetForm):
     model = ProviderNetwork
     model = ProviderNetwork
-    field_groups = (
-        ('q', 'tag'),
-        ('provider_id',),
+    fieldsets = (
+        (None, ('q', 'tag')),
+        ('Attributes', ('provider_id', 'service_id')),
     )
     )
     provider_id = DynamicModelMultipleChoiceField(
     provider_id = DynamicModelMultipleChoiceField(
         queryset=Provider.objects.all(),
         queryset=Provider.objects.all(),
@@ -67,20 +67,20 @@ class ProviderNetworkFilterForm(CustomFieldModelFilterForm):
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
-class CircuitTypeFilterForm(CustomFieldModelFilterForm):
+class CircuitTypeFilterForm(NetBoxModelFilterSetForm):
     model = CircuitType
     model = CircuitType
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
-class CircuitFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
+class CircuitFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
     model = Circuit
     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'],
-    ]
+    fieldsets = (
+        (None, ('q', 'tag')),
+        ('Provider', ('provider_id', 'provider_network_id')),
+        ('Attributes', ('type_id', 'status', 'commit_rate')),
+        ('Location', ('region_id', 'site_group_id', 'site_id')),
+        ('Tenant', ('tenant_group_id', 'tenant_id')),
+    )
     type_id = DynamicModelMultipleChoiceField(
     type_id = DynamicModelMultipleChoiceField(
         queryset=CircuitType.objects.all(),
         queryset=CircuitType.objects.all(),
         required=False,
         required=False,

+ 19 - 16
netbox/circuits/forms/models.py

@@ -2,8 +2,8 @@ from django import forms
 
 
 from circuits.models import *
 from circuits.models import *
 from dcim.models import Region, Site, SiteGroup
 from dcim.models import Region, Site, SiteGroup
-from extras.forms import CustomFieldModelForm
 from extras.models import Tag
 from extras.models import Tag
+from netbox.forms import NetBoxModelForm
 from tenancy.forms import TenancyForm
 from tenancy.forms import TenancyForm
 from utilities.forms import (
 from utilities.forms import (
     BootstrapMixin, CommentField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
     BootstrapMixin, CommentField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
@@ -19,7 +19,7 @@ __all__ = (
 )
 )
 
 
 
 
-class ProviderForm(CustomFieldModelForm):
+class ProviderForm(NetBoxModelForm):
     slug = SlugField()
     slug = SlugField()
     comments = CommentField()
     comments = CommentField()
     tags = DynamicModelMultipleChoiceField(
     tags = DynamicModelMultipleChoiceField(
@@ -27,15 +27,16 @@ class ProviderForm(CustomFieldModelForm):
         required=False
         required=False
     )
     )
 
 
+    fieldsets = (
+        ('Provider', ('name', 'slug', 'asn', 'tags')),
+        ('Support Info', ('account', 'portal_url', 'noc_contact', 'admin_contact')),
+    )
+
     class Meta:
     class Meta:
         model = Provider
         model = Provider
         fields = [
         fields = [
             'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', 'tags',
             '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 = {
         widgets = {
             'noc_contact': SmallTextarea(
             'noc_contact': SmallTextarea(
                 attrs={'rows': 5}
                 attrs={'rows': 5}
@@ -53,7 +54,7 @@ class ProviderForm(CustomFieldModelForm):
         }
         }
 
 
 
 
-class ProviderNetworkForm(CustomFieldModelForm):
+class ProviderNetworkForm(NetBoxModelForm):
     provider = DynamicModelChoiceField(
     provider = DynamicModelChoiceField(
         queryset=Provider.objects.all()
         queryset=Provider.objects.all()
     )
     )
@@ -63,17 +64,18 @@ class ProviderNetworkForm(CustomFieldModelForm):
         required=False
         required=False
     )
     )
 
 
+    fieldsets = (
+        ('Provider Network', ('provider', 'name', 'service_id', 'description', 'tags')),
+    )
+
     class Meta:
     class Meta:
         model = ProviderNetwork
         model = ProviderNetwork
         fields = [
         fields = [
             'provider', 'name', 'service_id', 'description', 'comments', 'tags',
             'provider', 'name', 'service_id', 'description', 'comments', 'tags',
         ]
         ]
-        fieldsets = (
-            ('Provider Network', ('provider', 'name', 'service_id', 'description', 'tags')),
-        )
 
 
 
 
-class CircuitTypeForm(CustomFieldModelForm):
+class CircuitTypeForm(NetBoxModelForm):
     slug = SlugField()
     slug = SlugField()
     tags = DynamicModelMultipleChoiceField(
     tags = DynamicModelMultipleChoiceField(
         queryset=Tag.objects.all(),
         queryset=Tag.objects.all(),
@@ -87,7 +89,7 @@ class CircuitTypeForm(CustomFieldModelForm):
         ]
         ]
 
 
 
 
-class CircuitForm(TenancyForm, CustomFieldModelForm):
+class CircuitForm(TenancyForm, NetBoxModelForm):
     provider = DynamicModelChoiceField(
     provider = DynamicModelChoiceField(
         queryset=Provider.objects.all()
         queryset=Provider.objects.all()
     )
     )
@@ -100,16 +102,17 @@ class CircuitForm(TenancyForm, CustomFieldModelForm):
         required=False
         required=False
     )
     )
 
 
+    fieldsets = (
+        ('Circuit', ('provider', 'cid', 'type', 'status', 'install_date', 'commit_rate', 'description', 'tags')),
+        ('Tenancy', ('tenant_group', 'tenant')),
+    )
+
     class Meta:
     class Meta:
         model = Circuit
         model = Circuit
         fields = [
         fields = [
             'cid', 'type', 'provider', 'status', 'install_date', 'commit_rate', 'description', 'tenant_group', 'tenant',
             'cid', 'type', 'provider', 'status', 'install_date', 'commit_rate', 'description', 'tenant_group', 'tenant',
             'comments', 'tags',
             'comments', 'tags',
         ]
         ]
-        fieldsets = (
-            ('Circuit', ('provider', 'cid', 'type', 'status', 'install_date', 'commit_rate', 'description', 'tags')),
-            ('Tenancy', ('tenant_group', 'tenant')),
-        )
         help_texts = {
         help_texts = {
             'cid': "Unique circuit ID",
             'cid': "Unique circuit ID",
             'commit_rate': "Committed rate",
             'commit_rate': "Committed rate",

+ 79 - 132
netbox/dcim/forms/bulk_edit.py

@@ -6,8 +6,8 @@ from timezone_field import TimeZoneFormField
 from dcim.choices import *
 from dcim.choices import *
 from dcim.constants import *
 from dcim.constants import *
 from dcim.models import *
 from dcim.models import *
-from extras.forms import AddRemoveTagsForm, CustomFieldModelBulkEditForm
 from ipam.models import ASN, VLAN, VRF
 from ipam.models import ASN, VLAN, VRF
+from netbox.forms import NetBoxModelBulkEditForm
 from tenancy.models import Tenant
 from tenancy.models import Tenant
 from utilities.forms import (
 from utilities.forms import (
     add_blank_choice, BulkEditForm, BulkEditNullBooleanSelect, ColorField, CommentField, DynamicModelChoiceField,
     add_blank_choice, BulkEditForm, BulkEditNullBooleanSelect, ColorField, CommentField, DynamicModelChoiceField,
@@ -57,7 +57,7 @@ __all__ = (
 )
 )
 
 
 
 
-class RegionBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class RegionBulkEditForm(NetBoxModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
         widget=forms.MultipleHiddenInput
         widget=forms.MultipleHiddenInput
@@ -71,11 +71,10 @@ class RegionBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
         required=False
         required=False
     )
     )
 
 
-    class Meta:
-        nullable_fields = ['parent', 'description']
+    nullable_fields = ('parent', 'description')
 
 
 
 
-class SiteGroupBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class SiteGroupBulkEditForm(NetBoxModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=SiteGroup.objects.all(),
         queryset=SiteGroup.objects.all(),
         widget=forms.MultipleHiddenInput
         widget=forms.MultipleHiddenInput
@@ -89,11 +88,10 @@ class SiteGroupBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
         required=False
         required=False
     )
     )
 
 
-    class Meta:
-        nullable_fields = ['parent', 'description']
+    nullable_fields = ('parent', 'description')
 
 
 
 
-class SiteBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class SiteBulkEditForm(NetBoxModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         widget=forms.MultipleHiddenInput
         widget=forms.MultipleHiddenInput
@@ -131,13 +129,12 @@ class SiteBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
         widget=StaticSelect()
         widget=StaticSelect()
     )
     )
 
 
-    class Meta:
-        nullable_fields = [
-            'region', 'group', 'tenant', 'asns', 'description', 'time_zone',
-        ]
+    nullable_fields = (
+        'region', 'group', 'tenant', 'asns', 'description', 'time_zone',
+    )
 
 
 
 
-class LocationBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class LocationBulkEditForm(NetBoxModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=Location.objects.all(),
         queryset=Location.objects.all(),
         widget=forms.MultipleHiddenInput
         widget=forms.MultipleHiddenInput
@@ -162,11 +159,10 @@ class LocationBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
         required=False
         required=False
     )
     )
 
 
-    class Meta:
-        nullable_fields = ['parent', 'tenant', 'description']
+    nullable_fields = ('parent', 'tenant', 'description')
 
 
 
 
-class RackRoleBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class RackRoleBulkEditForm(NetBoxModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=RackRole.objects.all(),
         queryset=RackRole.objects.all(),
         widget=forms.MultipleHiddenInput
         widget=forms.MultipleHiddenInput
@@ -179,11 +175,10 @@ class RackRoleBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
         required=False
         required=False
     )
     )
 
 
-    class Meta:
-        nullable_fields = ['color', 'description']
+    nullable_fields = ('color', 'description')
 
 
 
 
-class RackBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class RackBulkEditForm(NetBoxModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=Rack.objects.all(),
         queryset=Rack.objects.all(),
         widget=forms.MultipleHiddenInput
         widget=forms.MultipleHiddenInput
@@ -277,13 +272,12 @@ class RackBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
         label='Comments'
         label='Comments'
     )
     )
 
 
-    class Meta:
-        nullable_fields = [
-            'location', 'tenant', 'role', 'serial', 'asset_tag', 'outer_width', 'outer_depth', 'outer_unit', 'comments',
-        ]
+    nullable_fields = (
+        'location', 'tenant', 'role', 'serial', 'asset_tag', 'outer_width', 'outer_depth', 'outer_unit', 'comments',
+    )
 
 
 
 
-class RackReservationBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class RackReservationBulkEditForm(NetBoxModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=RackReservation.objects.all(),
         queryset=RackReservation.objects.all(),
         widget=forms.MultipleHiddenInput()
         widget=forms.MultipleHiddenInput()
@@ -304,11 +298,8 @@ class RackReservationBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditFor
         required=False
         required=False
     )
     )
 
 
-    class Meta:
-        nullable_fields = []
-
 
 
-class ManufacturerBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class ManufacturerBulkEditForm(NetBoxModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=Manufacturer.objects.all(),
         queryset=Manufacturer.objects.all(),
         widget=forms.MultipleHiddenInput
         widget=forms.MultipleHiddenInput
@@ -318,11 +309,10 @@ class ManufacturerBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
         required=False
         required=False
     )
     )
 
 
-    class Meta:
-        nullable_fields = ['description']
+    nullable_fields = ('description',)
 
 
 
 
-class DeviceTypeBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class DeviceTypeBulkEditForm(NetBoxModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=DeviceType.objects.all(),
         queryset=DeviceType.objects.all(),
         widget=forms.MultipleHiddenInput()
         widget=forms.MultipleHiddenInput()
@@ -349,11 +339,10 @@ class DeviceTypeBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
         widget=StaticSelect()
         widget=StaticSelect()
     )
     )
 
 
-    class Meta:
-        nullable_fields = ['part_number', 'airflow']
+    nullable_fields = ('part_number', 'airflow')
 
 
 
 
-class ModuleTypeBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class ModuleTypeBulkEditForm(NetBoxModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=ModuleType.objects.all(),
         queryset=ModuleType.objects.all(),
         widget=forms.MultipleHiddenInput()
         widget=forms.MultipleHiddenInput()
@@ -366,11 +355,10 @@ class ModuleTypeBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
         required=False
         required=False
     )
     )
 
 
-    class Meta:
-        nullable_fields = ['part_number']
+    nullable_fields = ('part_number',)
 
 
 
 
-class DeviceRoleBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class DeviceRoleBulkEditForm(NetBoxModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=DeviceRole.objects.all(),
         queryset=DeviceRole.objects.all(),
         widget=forms.MultipleHiddenInput
         widget=forms.MultipleHiddenInput
@@ -388,11 +376,10 @@ class DeviceRoleBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
         required=False
         required=False
     )
     )
 
 
-    class Meta:
-        nullable_fields = ['color', 'description']
+    nullable_fields = ('color', 'description')
 
 
 
 
-class PlatformBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class PlatformBulkEditForm(NetBoxModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=Platform.objects.all(),
         queryset=Platform.objects.all(),
         widget=forms.MultipleHiddenInput
         widget=forms.MultipleHiddenInput
@@ -411,11 +398,10 @@ class PlatformBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
         required=False
         required=False
     )
     )
 
 
-    class Meta:
-        nullable_fields = ['manufacturer', 'napalm_driver', 'description']
+    nullable_fields = ('manufacturer', 'napalm_driver', 'description')
 
 
 
 
-class DeviceBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class DeviceBulkEditForm(NetBoxModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=Device.objects.all(),
         queryset=Device.objects.all(),
         widget=forms.MultipleHiddenInput()
         widget=forms.MultipleHiddenInput()
@@ -470,13 +456,12 @@ class DeviceBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
         label='Serial Number'
         label='Serial Number'
     )
     )
 
 
-    class Meta:
-        nullable_fields = [
-            'tenant', 'platform', 'serial', 'airflow',
-        ]
+    nullable_fields = (
+        'tenant', 'platform', 'serial', 'airflow',
+    )
 
 
 
 
-class ModuleBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class ModuleBulkEditForm(NetBoxModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=Module.objects.all(),
         queryset=Module.objects.all(),
         widget=forms.MultipleHiddenInput()
         widget=forms.MultipleHiddenInput()
@@ -498,11 +483,10 @@ class ModuleBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
         label='Serial Number'
         label='Serial Number'
     )
     )
 
 
-    class Meta:
-        nullable_fields = ['serial']
+    nullable_fields = ('serial',)
 
 
 
 
-class CableBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class CableBulkEditForm(NetBoxModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=Cable.objects.all(),
         queryset=Cable.objects.all(),
         widget=forms.MultipleHiddenInput
         widget=forms.MultipleHiddenInput
@@ -541,10 +525,9 @@ class CableBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
         widget=StaticSelect()
         widget=StaticSelect()
     )
     )
 
 
-    class Meta:
-        nullable_fields = [
-            'type', 'status', 'tenant', 'label', 'color', 'length',
-        ]
+    nullable_fields = (
+        'type', 'status', 'tenant', 'label', 'color', 'length',
+    )
 
 
     def clean(self):
     def clean(self):
         super().clean()
         super().clean()
@@ -558,7 +541,7 @@ class CableBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
             })
             })
 
 
 
 
-class VirtualChassisBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class VirtualChassisBulkEditForm(NetBoxModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=VirtualChassis.objects.all(),
         queryset=VirtualChassis.objects.all(),
         widget=forms.MultipleHiddenInput()
         widget=forms.MultipleHiddenInput()
@@ -568,11 +551,10 @@ class VirtualChassisBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm
         required=False
         required=False
     )
     )
 
 
-    class Meta:
-        nullable_fields = ['domain']
+    nullable_fields = ('domain',)
 
 
 
 
-class PowerPanelBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class PowerPanelBulkEditForm(NetBoxModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=PowerPanel.objects.all(),
         queryset=PowerPanel.objects.all(),
         widget=forms.MultipleHiddenInput
         widget=forms.MultipleHiddenInput
@@ -607,11 +589,10 @@ class PowerPanelBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
         }
         }
     )
     )
 
 
-    class Meta:
-        nullable_fields = ['location']
+    nullable_fields = ('location',)
 
 
 
 
-class PowerFeedBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class PowerFeedBulkEditForm(NetBoxModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=PowerFeed.objects.all(),
         queryset=PowerFeed.objects.all(),
         widget=forms.MultipleHiddenInput
         widget=forms.MultipleHiddenInput
@@ -666,10 +647,7 @@ class PowerFeedBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
         label='Comments'
         label='Comments'
     )
     )
 
 
-    class Meta:
-        nullable_fields = [
-            'location', 'comments',
-        ]
+    nullable_fields = ('location', 'comments')
 
 
 
 
 #
 #
@@ -691,8 +669,7 @@ class ConsolePortTemplateBulkEditForm(BulkEditForm):
         widget=StaticSelect()
         widget=StaticSelect()
     )
     )
 
 
-    class Meta:
-        nullable_fields = ('label', 'type', 'description')
+    nullable_fields = ('label', 'type', 'description')
 
 
 
 
 class ConsoleServerPortTemplateBulkEditForm(BulkEditForm):
 class ConsoleServerPortTemplateBulkEditForm(BulkEditForm):
@@ -713,8 +690,7 @@ class ConsoleServerPortTemplateBulkEditForm(BulkEditForm):
         required=False
         required=False
     )
     )
 
 
-    class Meta:
-        nullable_fields = ('label', 'type', 'description')
+    nullable_fields = ('label', 'type', 'description')
 
 
 
 
 class PowerPortTemplateBulkEditForm(BulkEditForm):
 class PowerPortTemplateBulkEditForm(BulkEditForm):
@@ -745,8 +721,7 @@ class PowerPortTemplateBulkEditForm(BulkEditForm):
         required=False
         required=False
     )
     )
 
 
-    class Meta:
-        nullable_fields = ('label', 'type', 'maximum_draw', 'allocated_draw', 'description')
+    nullable_fields = ('label', 'type', 'maximum_draw', 'allocated_draw', 'description')
 
 
 
 
 class PowerOutletTemplateBulkEditForm(BulkEditForm):
 class PowerOutletTemplateBulkEditForm(BulkEditForm):
@@ -782,8 +757,7 @@ class PowerOutletTemplateBulkEditForm(BulkEditForm):
         required=False
         required=False
     )
     )
 
 
-    class Meta:
-        nullable_fields = ('label', 'type', 'power_port', 'feed_leg', 'description')
+    nullable_fields = ('label', 'type', 'power_port', 'feed_leg', 'description')
 
 
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
         super().__init__(*args, **kwargs)
@@ -820,8 +794,7 @@ class InterfaceTemplateBulkEditForm(BulkEditForm):
         required=False
         required=False
     )
     )
 
 
-    class Meta:
-        nullable_fields = ('label', 'description')
+    nullable_fields = ('label', 'description')
 
 
 
 
 class FrontPortTemplateBulkEditForm(BulkEditForm):
 class FrontPortTemplateBulkEditForm(BulkEditForm):
@@ -845,8 +818,7 @@ class FrontPortTemplateBulkEditForm(BulkEditForm):
         required=False
         required=False
     )
     )
 
 
-    class Meta:
-        nullable_fields = ('description',)
+    nullable_fields = ('description',)
 
 
 
 
 class RearPortTemplateBulkEditForm(BulkEditForm):
 class RearPortTemplateBulkEditForm(BulkEditForm):
@@ -870,8 +842,7 @@ class RearPortTemplateBulkEditForm(BulkEditForm):
         required=False
         required=False
     )
     )
 
 
-    class Meta:
-        nullable_fields = ('description',)
+    nullable_fields = ('description',)
 
 
 
 
 class ModuleBayTemplateBulkEditForm(BulkEditForm):
 class ModuleBayTemplateBulkEditForm(BulkEditForm):
@@ -887,8 +858,7 @@ class ModuleBayTemplateBulkEditForm(BulkEditForm):
         required=False
         required=False
     )
     )
 
 
-    class Meta:
-        nullable_fields = ('label', 'position', 'description')
+    nullable_fields = ('label', 'position', 'description')
 
 
 
 
 class DeviceBayTemplateBulkEditForm(BulkEditForm):
 class DeviceBayTemplateBulkEditForm(BulkEditForm):
@@ -904,8 +874,7 @@ class DeviceBayTemplateBulkEditForm(BulkEditForm):
         required=False
         required=False
     )
     )
 
 
-    class Meta:
-        nullable_fields = ('label', 'description')
+    nullable_fields = ('label', 'description')
 
 
 
 
 class InventoryItemTemplateBulkEditForm(BulkEditForm):
 class InventoryItemTemplateBulkEditForm(BulkEditForm):
@@ -929,8 +898,7 @@ class InventoryItemTemplateBulkEditForm(BulkEditForm):
         required=False
         required=False
     )
     )
 
 
-    class Meta:
-        nullable_fields = ['label', 'role', 'manufacturer', 'part_id', 'description']
+    nullable_fields = ('label', 'role', 'manufacturer', 'part_id', 'description')
 
 
 
 
 #
 #
@@ -939,8 +907,7 @@ class InventoryItemTemplateBulkEditForm(BulkEditForm):
 
 
 class ConsolePortBulkEditForm(
 class ConsolePortBulkEditForm(
     form_from_model(ConsolePort, ['label', 'type', 'speed', 'mark_connected', 'description']),
     form_from_model(ConsolePort, ['label', 'type', 'speed', 'mark_connected', 'description']),
-    AddRemoveTagsForm,
-    CustomFieldModelBulkEditForm
+    NetBoxModelBulkEditForm
 ):
 ):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=ConsolePort.objects.all(),
         queryset=ConsolePort.objects.all(),
@@ -951,14 +918,12 @@ class ConsolePortBulkEditForm(
         widget=BulkEditNullBooleanSelect
         widget=BulkEditNullBooleanSelect
     )
     )
 
 
-    class Meta:
-        nullable_fields = ['label', 'description']
+    nullable_fields = ('label', 'description')
 
 
 
 
 class ConsoleServerPortBulkEditForm(
 class ConsoleServerPortBulkEditForm(
     form_from_model(ConsoleServerPort, ['label', 'type', 'speed', 'mark_connected', 'description']),
     form_from_model(ConsoleServerPort, ['label', 'type', 'speed', 'mark_connected', 'description']),
-    AddRemoveTagsForm,
-    CustomFieldModelBulkEditForm
+    NetBoxModelBulkEditForm
 ):
 ):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=ConsoleServerPort.objects.all(),
         queryset=ConsoleServerPort.objects.all(),
@@ -969,14 +934,12 @@ class ConsoleServerPortBulkEditForm(
         widget=BulkEditNullBooleanSelect
         widget=BulkEditNullBooleanSelect
     )
     )
 
 
-    class Meta:
-        nullable_fields = ['label', 'description']
+    nullable_fields = ('label', 'description')
 
 
 
 
 class PowerPortBulkEditForm(
 class PowerPortBulkEditForm(
     form_from_model(PowerPort, ['label', 'type', 'maximum_draw', 'allocated_draw', 'mark_connected', 'description']),
     form_from_model(PowerPort, ['label', 'type', 'maximum_draw', 'allocated_draw', 'mark_connected', 'description']),
-    AddRemoveTagsForm,
-    CustomFieldModelBulkEditForm
+    NetBoxModelBulkEditForm
 ):
 ):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=PowerPort.objects.all(),
         queryset=PowerPort.objects.all(),
@@ -987,14 +950,12 @@ class PowerPortBulkEditForm(
         widget=BulkEditNullBooleanSelect
         widget=BulkEditNullBooleanSelect
     )
     )
 
 
-    class Meta:
-        nullable_fields = ['label', 'description']
+    nullable_fields = ('label', 'description')
 
 
 
 
 class PowerOutletBulkEditForm(
 class PowerOutletBulkEditForm(
     form_from_model(PowerOutlet, ['label', 'type', 'feed_leg', 'power_port', 'mark_connected', 'description']),
     form_from_model(PowerOutlet, ['label', 'type', 'feed_leg', 'power_port', 'mark_connected', 'description']),
-    AddRemoveTagsForm,
-    CustomFieldModelBulkEditForm
+    NetBoxModelBulkEditForm
 ):
 ):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=PowerOutlet.objects.all(),
         queryset=PowerOutlet.objects.all(),
@@ -1011,8 +972,7 @@ class PowerOutletBulkEditForm(
         widget=BulkEditNullBooleanSelect
         widget=BulkEditNullBooleanSelect
     )
     )
 
 
-    class Meta:
-        nullable_fields = ['label', 'type', 'feed_leg', 'power_port', 'description']
+    nullable_fields = ('label', 'type', 'feed_leg', 'power_port', 'description')
 
 
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
         super().__init__(*args, **kwargs)
@@ -1031,8 +991,7 @@ class InterfaceBulkEditForm(
         'label', 'type', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'mac_address', 'wwn', 'mtu', 'mgmt_only', 'mark_connected',
         'label', 'type', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'mac_address', 'wwn', 'mtu', 'mgmt_only', 'mark_connected',
         'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power',
         'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power',
     ]),
     ]),
-    AddRemoveTagsForm,
-    CustomFieldModelBulkEditForm
+    NetBoxModelBulkEditForm
 ):
 ):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=Interface.objects.all(),
         queryset=Interface.objects.all(),
@@ -1092,11 +1051,10 @@ class InterfaceBulkEditForm(
         label='VRF'
         label='VRF'
     )
     )
 
 
-    class Meta:
-        nullable_fields = [
-            'label', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'mac_address', 'wwn', 'mtu', 'description', 'mode', 'rf_channel',
-            'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan', 'tagged_vlans', 'vrf',
-        ]
+    nullable_fields = (
+        'label', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'mac_address', 'wwn', 'mtu', 'description', 'mode',
+        'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan', 'tagged_vlans', 'vrf',
+    )
 
 
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
         super().__init__(*args, **kwargs)
@@ -1154,64 +1112,55 @@ class InterfaceBulkEditForm(
 
 
 class FrontPortBulkEditForm(
 class FrontPortBulkEditForm(
     form_from_model(FrontPort, ['label', 'type', 'color', 'mark_connected', 'description']),
     form_from_model(FrontPort, ['label', 'type', 'color', 'mark_connected', 'description']),
-    AddRemoveTagsForm,
-    CustomFieldModelBulkEditForm
+    NetBoxModelBulkEditForm
 ):
 ):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=FrontPort.objects.all(),
         queryset=FrontPort.objects.all(),
         widget=forms.MultipleHiddenInput()
         widget=forms.MultipleHiddenInput()
     )
     )
 
 
-    class Meta:
-        nullable_fields = ['label', 'description']
+    nullable_fields = ('label', 'description')
 
 
 
 
 class RearPortBulkEditForm(
 class RearPortBulkEditForm(
     form_from_model(RearPort, ['label', 'type', 'color', 'mark_connected', 'description']),
     form_from_model(RearPort, ['label', 'type', 'color', 'mark_connected', 'description']),
-    AddRemoveTagsForm,
-    CustomFieldModelBulkEditForm
+    NetBoxModelBulkEditForm
 ):
 ):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=RearPort.objects.all(),
         queryset=RearPort.objects.all(),
         widget=forms.MultipleHiddenInput()
         widget=forms.MultipleHiddenInput()
     )
     )
 
 
-    class Meta:
-        nullable_fields = ['label', 'description']
+    nullable_fields = ('label', 'description')
 
 
 
 
 class ModuleBayBulkEditForm(
 class ModuleBayBulkEditForm(
     form_from_model(DeviceBay, ['label', 'description']),
     form_from_model(DeviceBay, ['label', 'description']),
-    AddRemoveTagsForm,
-    CustomFieldModelBulkEditForm
+    NetBoxModelBulkEditForm
 ):
 ):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=ModuleBay.objects.all(),
         queryset=ModuleBay.objects.all(),
         widget=forms.MultipleHiddenInput()
         widget=forms.MultipleHiddenInput()
     )
     )
 
 
-    class Meta:
-        nullable_fields = ['label', 'position', 'description']
+    nullable_fields = ('label', 'position', 'description')
 
 
 
 
 class DeviceBayBulkEditForm(
 class DeviceBayBulkEditForm(
     form_from_model(DeviceBay, ['label', 'description']),
     form_from_model(DeviceBay, ['label', 'description']),
-    AddRemoveTagsForm,
-    CustomFieldModelBulkEditForm
+    NetBoxModelBulkEditForm
 ):
 ):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=DeviceBay.objects.all(),
         queryset=DeviceBay.objects.all(),
         widget=forms.MultipleHiddenInput()
         widget=forms.MultipleHiddenInput()
     )
     )
 
 
-    class Meta:
-        nullable_fields = ['label', 'description']
+    nullable_fields = ('label', 'description')
 
 
 
 
 class InventoryItemBulkEditForm(
 class InventoryItemBulkEditForm(
     form_from_model(InventoryItem, ['label', 'role', 'manufacturer', 'part_id', 'description']),
     form_from_model(InventoryItem, ['label', 'role', 'manufacturer', 'part_id', 'description']),
-    AddRemoveTagsForm,
-    CustomFieldModelBulkEditForm
+    NetBoxModelBulkEditForm
 ):
 ):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=InventoryItem.objects.all(),
         queryset=InventoryItem.objects.all(),
@@ -1226,15 +1175,14 @@ class InventoryItemBulkEditForm(
         required=False
         required=False
     )
     )
 
 
-    class Meta:
-        nullable_fields = ['label', 'role', 'manufacturer', 'part_id', 'description']
+    nullable_fields = ('label', 'role', 'manufacturer', 'part_id', 'description')
 
 
 
 
 #
 #
 # Device component roles
 # Device component roles
 #
 #
 
 
-class InventoryItemRoleBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class InventoryItemRoleBulkEditForm(NetBoxModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=InventoryItemRole.objects.all(),
         queryset=InventoryItemRole.objects.all(),
         widget=forms.MultipleHiddenInput
         widget=forms.MultipleHiddenInput
@@ -1247,5 +1195,4 @@ class InventoryItemRoleBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditF
         required=False
         required=False
     )
     )
 
 
-    class Meta:
-        nullable_fields = ['color', 'description']
+    nullable_fields = ('color', 'description')

+ 28 - 28
netbox/dcim/forms/bulk_import.py

@@ -7,8 +7,8 @@ from django.utils.safestring import mark_safe
 from dcim.choices import *
 from dcim.choices import *
 from dcim.constants import *
 from dcim.constants import *
 from dcim.models import *
 from dcim.models import *
-from extras.forms import CustomFieldModelCSVForm
 from ipam.models import VRF
 from ipam.models import VRF
+from netbox.forms import NetBoxModelCSVForm
 from tenancy.models import Tenant
 from tenancy.models import Tenant
 from utilities.forms import CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVTypedChoiceField, SlugField
 from utilities.forms import CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVTypedChoiceField, SlugField
 from virtualization.models import Cluster
 from virtualization.models import Cluster
@@ -46,7 +46,7 @@ __all__ = (
 )
 )
 
 
 
 
-class RegionCSVForm(CustomFieldModelCSVForm):
+class RegionCSVForm(NetBoxModelCSVForm):
     parent = CSVModelChoiceField(
     parent = CSVModelChoiceField(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
         required=False,
         required=False,
@@ -59,7 +59,7 @@ class RegionCSVForm(CustomFieldModelCSVForm):
         fields = ('name', 'slug', 'parent', 'description')
         fields = ('name', 'slug', 'parent', 'description')
 
 
 
 
-class SiteGroupCSVForm(CustomFieldModelCSVForm):
+class SiteGroupCSVForm(NetBoxModelCSVForm):
     parent = CSVModelChoiceField(
     parent = CSVModelChoiceField(
         queryset=SiteGroup.objects.all(),
         queryset=SiteGroup.objects.all(),
         required=False,
         required=False,
@@ -72,7 +72,7 @@ class SiteGroupCSVForm(CustomFieldModelCSVForm):
         fields = ('name', 'slug', 'parent', 'description')
         fields = ('name', 'slug', 'parent', 'description')
 
 
 
 
-class SiteCSVForm(CustomFieldModelCSVForm):
+class SiteCSVForm(NetBoxModelCSVForm):
     status = CSVChoiceField(
     status = CSVChoiceField(
         choices=SiteStatusChoices,
         choices=SiteStatusChoices,
         help_text='Operational status'
         help_text='Operational status'
@@ -109,7 +109,7 @@ class SiteCSVForm(CustomFieldModelCSVForm):
         }
         }
 
 
 
 
-class LocationCSVForm(CustomFieldModelCSVForm):
+class LocationCSVForm(NetBoxModelCSVForm):
     site = CSVModelChoiceField(
     site = CSVModelChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         to_field_name='name',
         to_field_name='name',
@@ -136,7 +136,7 @@ class LocationCSVForm(CustomFieldModelCSVForm):
         fields = ('site', 'parent', 'name', 'slug', 'tenant', 'description')
         fields = ('site', 'parent', 'name', 'slug', 'tenant', 'description')
 
 
 
 
-class RackRoleCSVForm(CustomFieldModelCSVForm):
+class RackRoleCSVForm(NetBoxModelCSVForm):
     slug = SlugField()
     slug = SlugField()
 
 
     class Meta:
     class Meta:
@@ -147,7 +147,7 @@ class RackRoleCSVForm(CustomFieldModelCSVForm):
         }
         }
 
 
 
 
-class RackCSVForm(CustomFieldModelCSVForm):
+class RackCSVForm(NetBoxModelCSVForm):
     site = CSVModelChoiceField(
     site = CSVModelChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         to_field_name='name'
         to_field_name='name'
@@ -205,7 +205,7 @@ class RackCSVForm(CustomFieldModelCSVForm):
             self.fields['location'].queryset = self.fields['location'].queryset.filter(**params)
             self.fields['location'].queryset = self.fields['location'].queryset.filter(**params)
 
 
 
 
-class RackReservationCSVForm(CustomFieldModelCSVForm):
+class RackReservationCSVForm(NetBoxModelCSVForm):
     site = CSVModelChoiceField(
     site = CSVModelChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         to_field_name='name',
         to_field_name='name',
@@ -255,14 +255,14 @@ class RackReservationCSVForm(CustomFieldModelCSVForm):
             self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params)
             self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params)
 
 
 
 
-class ManufacturerCSVForm(CustomFieldModelCSVForm):
+class ManufacturerCSVForm(NetBoxModelCSVForm):
 
 
     class Meta:
     class Meta:
         model = Manufacturer
         model = Manufacturer
         fields = ('name', 'slug', 'description')
         fields = ('name', 'slug', 'description')
 
 
 
 
-class DeviceRoleCSVForm(CustomFieldModelCSVForm):
+class DeviceRoleCSVForm(NetBoxModelCSVForm):
     slug = SlugField()
     slug = SlugField()
 
 
     class Meta:
     class Meta:
@@ -273,7 +273,7 @@ class DeviceRoleCSVForm(CustomFieldModelCSVForm):
         }
         }
 
 
 
 
-class PlatformCSVForm(CustomFieldModelCSVForm):
+class PlatformCSVForm(NetBoxModelCSVForm):
     slug = SlugField()
     slug = SlugField()
     manufacturer = CSVModelChoiceField(
     manufacturer = CSVModelChoiceField(
         queryset=Manufacturer.objects.all(),
         queryset=Manufacturer.objects.all(),
@@ -287,7 +287,7 @@ class PlatformCSVForm(CustomFieldModelCSVForm):
         fields = ('name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description')
         fields = ('name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description')
 
 
 
 
-class BaseDeviceCSVForm(CustomFieldModelCSVForm):
+class BaseDeviceCSVForm(NetBoxModelCSVForm):
     device_role = CSVModelChoiceField(
     device_role = CSVModelChoiceField(
         queryset=DeviceRole.objects.all(),
         queryset=DeviceRole.objects.all(),
         to_field_name='name',
         to_field_name='name',
@@ -403,7 +403,7 @@ class DeviceCSVForm(BaseDeviceCSVForm):
             self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params)
             self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params)
 
 
 
 
-class ModuleCSVForm(CustomFieldModelCSVForm):
+class ModuleCSVForm(NetBoxModelCSVForm):
     device = CSVModelChoiceField(
     device = CSVModelChoiceField(
         queryset=Device.objects.all(),
         queryset=Device.objects.all(),
         to_field_name='name'
         to_field_name='name'
@@ -478,7 +478,7 @@ class ChildDeviceCSVForm(BaseDeviceCSVForm):
 # Device components
 # Device components
 #
 #
 
 
-class ConsolePortCSVForm(CustomFieldModelCSVForm):
+class ConsolePortCSVForm(NetBoxModelCSVForm):
     device = CSVModelChoiceField(
     device = CSVModelChoiceField(
         queryset=Device.objects.all(),
         queryset=Device.objects.all(),
         to_field_name='name'
         to_field_name='name'
@@ -501,7 +501,7 @@ class ConsolePortCSVForm(CustomFieldModelCSVForm):
         fields = ('device', 'name', 'label', 'type', 'speed', 'mark_connected', 'description')
         fields = ('device', 'name', 'label', 'type', 'speed', 'mark_connected', 'description')
 
 
 
 
-class ConsoleServerPortCSVForm(CustomFieldModelCSVForm):
+class ConsoleServerPortCSVForm(NetBoxModelCSVForm):
     device = CSVModelChoiceField(
     device = CSVModelChoiceField(
         queryset=Device.objects.all(),
         queryset=Device.objects.all(),
         to_field_name='name'
         to_field_name='name'
@@ -524,7 +524,7 @@ class ConsoleServerPortCSVForm(CustomFieldModelCSVForm):
         fields = ('device', 'name', 'label', 'type', 'speed', 'mark_connected', 'description')
         fields = ('device', 'name', 'label', 'type', 'speed', 'mark_connected', 'description')
 
 
 
 
-class PowerPortCSVForm(CustomFieldModelCSVForm):
+class PowerPortCSVForm(NetBoxModelCSVForm):
     device = CSVModelChoiceField(
     device = CSVModelChoiceField(
         queryset=Device.objects.all(),
         queryset=Device.objects.all(),
         to_field_name='name'
         to_field_name='name'
@@ -542,7 +542,7 @@ class PowerPortCSVForm(CustomFieldModelCSVForm):
         )
         )
 
 
 
 
-class PowerOutletCSVForm(CustomFieldModelCSVForm):
+class PowerOutletCSVForm(NetBoxModelCSVForm):
     device = CSVModelChoiceField(
     device = CSVModelChoiceField(
         queryset=Device.objects.all(),
         queryset=Device.objects.all(),
         to_field_name='name'
         to_field_name='name'
@@ -591,7 +591,7 @@ class PowerOutletCSVForm(CustomFieldModelCSVForm):
             self.fields['power_port'].queryset = PowerPort.objects.none()
             self.fields['power_port'].queryset = PowerPort.objects.none()
 
 
 
 
-class InterfaceCSVForm(CustomFieldModelCSVForm):
+class InterfaceCSVForm(NetBoxModelCSVForm):
     device = CSVModelChoiceField(
     device = CSVModelChoiceField(
         queryset=Device.objects.all(),
         queryset=Device.objects.all(),
         to_field_name='name'
         to_field_name='name'
@@ -655,7 +655,7 @@ class InterfaceCSVForm(CustomFieldModelCSVForm):
             return self.cleaned_data['enabled']
             return self.cleaned_data['enabled']
 
 
 
 
-class FrontPortCSVForm(CustomFieldModelCSVForm):
+class FrontPortCSVForm(NetBoxModelCSVForm):
     device = CSVModelChoiceField(
     device = CSVModelChoiceField(
         queryset=Device.objects.all(),
         queryset=Device.objects.all(),
         to_field_name='name'
         to_field_name='name'
@@ -703,7 +703,7 @@ class FrontPortCSVForm(CustomFieldModelCSVForm):
             self.fields['rear_port'].queryset = RearPort.objects.none()
             self.fields['rear_port'].queryset = RearPort.objects.none()
 
 
 
 
-class RearPortCSVForm(CustomFieldModelCSVForm):
+class RearPortCSVForm(NetBoxModelCSVForm):
     device = CSVModelChoiceField(
     device = CSVModelChoiceField(
         queryset=Device.objects.all(),
         queryset=Device.objects.all(),
         to_field_name='name'
         to_field_name='name'
@@ -721,7 +721,7 @@ class RearPortCSVForm(CustomFieldModelCSVForm):
         }
         }
 
 
 
 
-class ModuleBayCSVForm(CustomFieldModelCSVForm):
+class ModuleBayCSVForm(NetBoxModelCSVForm):
     device = CSVModelChoiceField(
     device = CSVModelChoiceField(
         queryset=Device.objects.all(),
         queryset=Device.objects.all(),
         to_field_name='name'
         to_field_name='name'
@@ -732,7 +732,7 @@ class ModuleBayCSVForm(CustomFieldModelCSVForm):
         fields = ('device', 'name', 'label', 'position', 'description')
         fields = ('device', 'name', 'label', 'position', 'description')
 
 
 
 
-class DeviceBayCSVForm(CustomFieldModelCSVForm):
+class DeviceBayCSVForm(NetBoxModelCSVForm):
     device = CSVModelChoiceField(
     device = CSVModelChoiceField(
         queryset=Device.objects.all(),
         queryset=Device.objects.all(),
         to_field_name='name'
         to_field_name='name'
@@ -778,7 +778,7 @@ class DeviceBayCSVForm(CustomFieldModelCSVForm):
             self.fields['installed_device'].queryset = Interface.objects.none()
             self.fields['installed_device'].queryset = Interface.objects.none()
 
 
 
 
-class InventoryItemCSVForm(CustomFieldModelCSVForm):
+class InventoryItemCSVForm(NetBoxModelCSVForm):
     device = CSVModelChoiceField(
     device = CSVModelChoiceField(
         queryset=Device.objects.all(),
         queryset=Device.objects.all(),
         to_field_name='name'
         to_field_name='name'
@@ -827,7 +827,7 @@ class InventoryItemCSVForm(CustomFieldModelCSVForm):
 # Device component roles
 # Device component roles
 #
 #
 
 
-class InventoryItemRoleCSVForm(CustomFieldModelCSVForm):
+class InventoryItemRoleCSVForm(NetBoxModelCSVForm):
     slug = SlugField()
     slug = SlugField()
 
 
     class Meta:
     class Meta:
@@ -842,7 +842,7 @@ class InventoryItemRoleCSVForm(CustomFieldModelCSVForm):
 # Cables
 # Cables
 #
 #
 
 
-class CableCSVForm(CustomFieldModelCSVForm):
+class CableCSVForm(NetBoxModelCSVForm):
     # Termination A
     # Termination A
     side_a_device = CSVModelChoiceField(
     side_a_device = CSVModelChoiceField(
         queryset=Device.objects.all(),
         queryset=Device.objects.all(),
@@ -947,7 +947,7 @@ class CableCSVForm(CustomFieldModelCSVForm):
 # Virtual chassis
 # Virtual chassis
 #
 #
 
 
-class VirtualChassisCSVForm(CustomFieldModelCSVForm):
+class VirtualChassisCSVForm(NetBoxModelCSVForm):
     master = CSVModelChoiceField(
     master = CSVModelChoiceField(
         queryset=Device.objects.all(),
         queryset=Device.objects.all(),
         to_field_name='name',
         to_field_name='name',
@@ -964,7 +964,7 @@ class VirtualChassisCSVForm(CustomFieldModelCSVForm):
 # Power
 # Power
 #
 #
 
 
-class PowerPanelCSVForm(CustomFieldModelCSVForm):
+class PowerPanelCSVForm(NetBoxModelCSVForm):
     site = CSVModelChoiceField(
     site = CSVModelChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         to_field_name='name',
         to_field_name='name',
@@ -990,7 +990,7 @@ class PowerPanelCSVForm(CustomFieldModelCSVForm):
             self.fields['location'].queryset = self.fields['location'].queryset.filter(**params)
             self.fields['location'].queryset = self.fields['location'].queryset.filter(**params)
 
 
 
 
-class PowerFeedCSVForm(CustomFieldModelCSVForm):
+class PowerFeedCSVForm(NetBoxModelCSVForm):
     site = CSVModelChoiceField(
     site = CSVModelChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         to_field_name='name',
         to_field_name='name',

+ 4 - 4
netbox/dcim/forms/connections.py

@@ -1,7 +1,7 @@
 from circuits.models import Circuit, CircuitTermination, Provider
 from circuits.models import Circuit, CircuitTermination, Provider
 from dcim.models import *
 from dcim.models import *
-from extras.forms import CustomFieldModelForm
 from extras.models import Tag
 from extras.models import Tag
+from netbox.forms import NetBoxModelForm
 from tenancy.forms import TenancyForm
 from tenancy.forms import TenancyForm
 from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField, StaticSelect
 from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField, StaticSelect
 
 
@@ -18,7 +18,7 @@ __all__ = (
 )
 )
 
 
 
 
-class ConnectCableToDeviceForm(TenancyForm, CustomFieldModelForm):
+class ConnectCableToDeviceForm(TenancyForm, NetBoxModelForm):
     """
     """
     Base form for connecting a Cable to a Device component
     Base form for connecting a Cable to a Device component
     """
     """
@@ -171,7 +171,7 @@ class ConnectCableToRearPortForm(ConnectCableToDeviceForm):
     )
     )
 
 
 
 
-class ConnectCableToCircuitTerminationForm(TenancyForm, CustomFieldModelForm):
+class ConnectCableToCircuitTerminationForm(TenancyForm, NetBoxModelForm):
     termination_b_provider = DynamicModelChoiceField(
     termination_b_provider = DynamicModelChoiceField(
         queryset=Provider.objects.all(),
         queryset=Provider.objects.all(),
         label='Provider',
         label='Provider',
@@ -229,7 +229,7 @@ class ConnectCableToCircuitTerminationForm(TenancyForm, CustomFieldModelForm):
         return getattr(self.cleaned_data['termination_b_id'], 'pk', None)
         return getattr(self.cleaned_data['termination_b_id'], 'pk', None)
 
 
 
 
-class ConnectCableToPowerFeedForm(TenancyForm, CustomFieldModelForm):
+class ConnectCableToPowerFeedForm(TenancyForm, NetBoxModelForm):
     termination_b_region = DynamicModelChoiceField(
     termination_b_region = DynamicModelChoiceField(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
         label='Region',
         label='Region',

+ 147 - 143
netbox/dcim/forms/filtersets.py

@@ -5,8 +5,9 @@ from django.utils.translation import gettext as _
 from dcim.choices import *
 from dcim.choices import *
 from dcim.constants import *
 from dcim.constants import *
 from dcim.models import *
 from dcim.models import *
-from extras.forms import CustomFieldModelFilterForm, LocalConfigContextFilterForm
+from extras.forms import LocalConfigContextFilterForm
 from ipam.models import ASN, VRF
 from ipam.models import ASN, VRF
+from netbox.forms import NetBoxModelFilterSetForm
 from tenancy.forms import TenancyFilterForm
 from tenancy.forms import TenancyFilterForm
 from utilities.forms import (
 from utilities.forms import (
     APISelectMultiple, add_blank_choice, ColorField, DynamicModelMultipleChoiceField, FilterForm, StaticSelect,
     APISelectMultiple, add_blank_choice, ColorField, DynamicModelMultipleChoiceField, FilterForm, StaticSelect,
@@ -52,7 +53,7 @@ __all__ = (
 )
 )
 
 
 
 
-class DeviceComponentFilterForm(CustomFieldModelFilterForm):
+class DeviceComponentFilterForm(NetBoxModelFilterSetForm):
     name = forms.CharField(
     name = forms.CharField(
         required=False
         required=False
     )
     )
@@ -103,7 +104,7 @@ class DeviceComponentFilterForm(CustomFieldModelFilterForm):
     )
     )
 
 
 
 
-class RegionFilterForm(CustomFieldModelFilterForm):
+class RegionFilterForm(NetBoxModelFilterSetForm):
     model = Region
     model = Region
     parent_id = DynamicModelMultipleChoiceField(
     parent_id = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
@@ -113,7 +114,7 @@ class RegionFilterForm(CustomFieldModelFilterForm):
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
-class SiteGroupFilterForm(CustomFieldModelFilterForm):
+class SiteGroupFilterForm(NetBoxModelFilterSetForm):
     model = SiteGroup
     model = SiteGroup
     parent_id = DynamicModelMultipleChoiceField(
     parent_id = DynamicModelMultipleChoiceField(
         queryset=SiteGroup.objects.all(),
         queryset=SiteGroup.objects.all(),
@@ -123,14 +124,13 @@ class SiteGroupFilterForm(CustomFieldModelFilterForm):
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
-class SiteFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
+class SiteFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
     model = Site
     model = Site
-    field_groups = [
-        ['q', 'tag'],
-        ['status', 'region_id', 'group_id'],
-        ['tenant_group_id', 'tenant_id'],
-        ['asn_id']
-    ]
+    fieldsets = (
+        (None, ('q', 'tag')),
+        ('Attributes', ('status', 'region_id', 'group_id', 'asn_id')),
+        ('Tenant', ('tenant_group_id', 'tenant_id')),
+    )
     status = forms.MultipleChoiceField(
     status = forms.MultipleChoiceField(
         choices=SiteStatusChoices,
         choices=SiteStatusChoices,
         required=False,
         required=False,
@@ -154,13 +154,13 @@ class SiteFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
-class LocationFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
+class LocationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
     model = Location
     model = Location
-    field_groups = [
-        ['q', 'tag'],
-        ['region_id', 'site_group_id', 'site_id', 'parent_id'],
-        ['tenant_group_id', 'tenant_id'],
-    ]
+    fieldsets = (
+        (None, ('q', 'tag')),
+        ('Parent', ('region_id', 'site_group_id', 'site_id', 'parent_id')),
+        ('Tenant', ('tenant_group_id', 'tenant_id')),
+    )
     region_id = DynamicModelMultipleChoiceField(
     region_id = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
         required=False,
         required=False,
@@ -192,20 +192,20 @@ class LocationFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
-class RackRoleFilterForm(CustomFieldModelFilterForm):
+class RackRoleFilterForm(NetBoxModelFilterSetForm):
     model = RackRole
     model = RackRole
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
-class RackFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
+class RackFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
     model = Rack
     model = Rack
-    field_groups = [
-        ['q', 'tag'],
-        ['region_id', 'site_id', 'location_id'],
-        ['status', 'role_id'],
-        ['type', 'width', 'serial', 'asset_tag'],
-        ['tenant_group_id', 'tenant_id'],
-    ]
+    fieldsets = (
+        (None, ('q', 'tag')),
+        ('Location', ('region_id', 'site_id', 'location_id')),
+        ('Function', ('status', 'role_id')),
+        ('Hardware', ('type', 'width', 'serial', 'asset_tag')),
+        ('Tenant', ('tenant_group_id', 'tenant_id')),
+    )
     region_id = DynamicModelMultipleChoiceField(
     region_id = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
         required=False,
         required=False,
@@ -270,14 +270,14 @@ class RackElevationFilterForm(RackFilterForm):
     )
     )
 
 
 
 
-class RackReservationFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
+class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
     model = RackReservation
     model = RackReservation
-    field_groups = [
-        ['q', 'tag'],
-        ['user_id'],
-        ['region_id', 'site_id', 'location_id'],
-        ['tenant_group_id', 'tenant_id'],
-    ]
+    fieldsets = (
+        (None, ('q', 'tag')),
+        ('User', ('user_id',)),
+        ('Rack', ('region_id', 'site_id', 'location_id')),
+        ('Tenant', ('tenant_group_id', 'tenant_id')),
+    )
     region_id = DynamicModelMultipleChoiceField(
     region_id = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
         required=False,
         required=False,
@@ -308,18 +308,21 @@ class RackReservationFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
-class ManufacturerFilterForm(CustomFieldModelFilterForm):
+class ManufacturerFilterForm(NetBoxModelFilterSetForm):
     model = Manufacturer
     model = Manufacturer
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
-class DeviceTypeFilterForm(CustomFieldModelFilterForm):
+class DeviceTypeFilterForm(NetBoxModelFilterSetForm):
     model = DeviceType
     model = DeviceType
-    field_groups = [
-        ['q', 'tag'],
-        ['manufacturer_id', 'part_number', 'subdevice_role', 'airflow'],
-        ['console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports'],
-    ]
+    fieldsets = (
+        (None, ('q', 'tag')),
+        ('Hardware', ('manufacturer_id', 'part_number', 'subdevice_role', 'airflow')),
+        ('Components', (
+            'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces',
+            'pass_through_ports',
+        )),
+    )
     manufacturer_id = DynamicModelMultipleChoiceField(
     manufacturer_id = DynamicModelMultipleChoiceField(
         queryset=Manufacturer.objects.all(),
         queryset=Manufacturer.objects.all(),
         required=False,
         required=False,
@@ -383,13 +386,16 @@ class DeviceTypeFilterForm(CustomFieldModelFilterForm):
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
-class ModuleTypeFilterForm(CustomFieldModelFilterForm):
+class ModuleTypeFilterForm(NetBoxModelFilterSetForm):
     model = ModuleType
     model = ModuleType
-    field_groups = [
-        ['q', 'tag'],
-        ['manufacturer_id', 'part_number'],
-        ['console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports'],
-    ]
+    fieldsets = (
+        (None, ('q', 'tag')),
+        ('Hardware', ('manufacturer_id', 'part_number')),
+        ('Components', (
+            'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces',
+            'pass_through_ports',
+        )),
+    )
     manufacturer_id = DynamicModelMultipleChoiceField(
     manufacturer_id = DynamicModelMultipleChoiceField(
         queryset=Manufacturer.objects.all(),
         queryset=Manufacturer.objects.all(),
         required=False,
         required=False,
@@ -444,12 +450,12 @@ class ModuleTypeFilterForm(CustomFieldModelFilterForm):
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
-class DeviceRoleFilterForm(CustomFieldModelFilterForm):
+class DeviceRoleFilterForm(NetBoxModelFilterSetForm):
     model = DeviceRole
     model = DeviceRole
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
-class PlatformFilterForm(CustomFieldModelFilterForm):
+class PlatformFilterForm(NetBoxModelFilterSetForm):
     model = Platform
     model = Platform
     manufacturer_id = DynamicModelMultipleChoiceField(
     manufacturer_id = DynamicModelMultipleChoiceField(
         queryset=Manufacturer.objects.all(),
         queryset=Manufacturer.objects.all(),
@@ -459,19 +465,19 @@ class PlatformFilterForm(CustomFieldModelFilterForm):
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
-class DeviceFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, CustomFieldModelFilterForm):
+class DeviceFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, NetBoxModelFilterSetForm):
     model = Device
     model = Device
-    field_groups = [
-        ['q', 'tag'],
-        ['region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id'],
-        ['status', 'role_id', 'airflow', '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',
-        ],
-    ]
+    fieldsets = (
+        (None, ('q', 'tag')),
+        ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
+        ('Operation', ('status', 'role_id', 'airflow', 'serial', 'asset_tag', 'mac_address')),
+        ('Hardware', ('manufacturer_id', 'device_type_id', 'platform_id')),
+        ('Tenant', ('tenant_group_id', 'tenant_id')),
+        ('Components', (
+            'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports',
+        )),
+        ('Miscellaneous', ('has_primary_ip', 'virtual_chassis_member', 'local_context_data'))
+    )
     region_id = DynamicModelMultipleChoiceField(
     region_id = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
         required=False,
         required=False,
@@ -613,13 +619,12 @@ class DeviceFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, CustomFi
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
-class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, CustomFieldModelFilterForm):
+class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, NetBoxModelFilterSetForm):
     model = Module
     model = Module
-    field_groups = [
-        ['q', 'tag'],
-        ['manufacturer_id', 'module_type_id'],
-        ['serial', 'asset_tag'],
-    ]
+    fieldsets = (
+        (None, ('q', 'tag')),
+        ('Hardware', ('manufacturer_id', 'module_type_id', 'serial', 'asset_tag')),
+    )
     manufacturer_id = DynamicModelMultipleChoiceField(
     manufacturer_id = DynamicModelMultipleChoiceField(
         queryset=Manufacturer.objects.all(),
         queryset=Manufacturer.objects.all(),
         required=False,
         required=False,
@@ -644,13 +649,13 @@ class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, CustomFi
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
-class VirtualChassisFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
+class VirtualChassisFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
     model = VirtualChassis
     model = VirtualChassis
-    field_groups = [
-        ['q', 'tag'],
-        ['region_id', 'site_group_id', 'site_id'],
-        ['tenant_group_id', 'tenant_id'],
-    ]
+    fieldsets = (
+        (None, ('q', 'tag')),
+        ('Location', ('region_id', 'site_group_id', 'site_id')),
+        ('Tenant', ('tenant_group_id', 'tenant_id')),
+    )
     region_id = DynamicModelMultipleChoiceField(
     region_id = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
         required=False,
         required=False,
@@ -673,14 +678,14 @@ class VirtualChassisFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
-class CableFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
+class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
     model = Cable
     model = Cable
-    field_groups = [
-        ['q', 'tag'],
-        ['site_id', 'rack_id', 'device_id'],
-        ['type', 'status', 'color', 'length', 'length_unit'],
-        ['tenant_group_id', 'tenant_id'],
-    ]
+    fieldsets = (
+        (None, ('q', 'tag')),
+        ('Location', ('site_id', 'rack_id', 'device_id')),
+        ('Attributes', ('type', 'status', 'color', 'length', 'length_unit')),
+        ('Tenant', ('tenant_group_id', 'tenant_id')),
+    )
     region_id = DynamicModelMultipleChoiceField(
     region_id = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
         required=False,
         required=False,
@@ -736,11 +741,11 @@ class CableFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
-class PowerPanelFilterForm(CustomFieldModelFilterForm):
+class PowerPanelFilterForm(NetBoxModelFilterSetForm):
     model = PowerPanel
     model = PowerPanel
-    field_groups = (
-        ('q', 'tag'),
-        ('region_id', 'site_group_id', 'site_id', 'location_id')
+    fieldsets = (
+        (None, ('q', 'tag')),
+        ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id'))
     )
     )
     region_id = DynamicModelMultipleChoiceField(
     region_id = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
@@ -773,14 +778,13 @@ class PowerPanelFilterForm(CustomFieldModelFilterForm):
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
-class PowerFeedFilterForm(CustomFieldModelFilterForm):
+class PowerFeedFilterForm(NetBoxModelFilterSetForm):
     model = PowerFeed
     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'],
-    ]
+    fieldsets = (
+        (None, ('q', 'tag')),
+        ('Location', ('region_id', 'site_group_id', 'site_id', 'power_panel_id', 'rack_id')),
+        ('Attributes', ('status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization')),
+    )
     region_id = DynamicModelMultipleChoiceField(
     region_id = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
         required=False,
         required=False,
@@ -855,11 +859,11 @@ class PowerFeedFilterForm(CustomFieldModelFilterForm):
 
 
 class ConsolePortFilterForm(DeviceComponentFilterForm):
 class ConsolePortFilterForm(DeviceComponentFilterForm):
     model = ConsolePort
     model = ConsolePort
-    field_groups = [
-        ['q', 'tag'],
-        ['name', 'label', 'type', 'speed'],
-        ['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'],
-    ]
+    fieldsets = (
+        (None, ('q', 'tag')),
+        ('Attributes', ('name', 'label', 'type', 'speed')),
+        ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id')),
+    )
     type = forms.MultipleChoiceField(
     type = forms.MultipleChoiceField(
         choices=ConsolePortTypeChoices,
         choices=ConsolePortTypeChoices,
         required=False,
         required=False,
@@ -875,11 +879,11 @@ class ConsolePortFilterForm(DeviceComponentFilterForm):
 
 
 class ConsoleServerPortFilterForm(DeviceComponentFilterForm):
 class ConsoleServerPortFilterForm(DeviceComponentFilterForm):
     model = ConsoleServerPort
     model = ConsoleServerPort
-    field_groups = [
-        ['q', 'tag'],
-        ['name', 'label', 'type', 'speed'],
-        ['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'],
-    ]
+    fieldsets = (
+        (None, ('q', 'tag')),
+        ('Attributes', ('name', 'label', 'type', 'speed')),
+        ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id')),
+    )
     type = forms.MultipleChoiceField(
     type = forms.MultipleChoiceField(
         choices=ConsolePortTypeChoices,
         choices=ConsolePortTypeChoices,
         required=False,
         required=False,
@@ -895,11 +899,11 @@ class ConsoleServerPortFilterForm(DeviceComponentFilterForm):
 
 
 class PowerPortFilterForm(DeviceComponentFilterForm):
 class PowerPortFilterForm(DeviceComponentFilterForm):
     model = PowerPort
     model = PowerPort
-    field_groups = [
-        ['q', 'tag'],
-        ['name', 'label', 'type'],
-        ['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'],
-    ]
+    fieldsets = (
+        (None, ('q', 'tag')),
+        ('Attributes', ('name', 'label', 'type')),
+        ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id')),
+    )
     type = forms.MultipleChoiceField(
     type = forms.MultipleChoiceField(
         choices=PowerPortTypeChoices,
         choices=PowerPortTypeChoices,
         required=False,
         required=False,
@@ -910,11 +914,11 @@ class PowerPortFilterForm(DeviceComponentFilterForm):
 
 
 class PowerOutletFilterForm(DeviceComponentFilterForm):
 class PowerOutletFilterForm(DeviceComponentFilterForm):
     model = PowerOutlet
     model = PowerOutlet
-    field_groups = [
-        ['q', 'tag'],
-        ['name', 'label', 'type'],
-        ['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'],
-    ]
+    fieldsets = (
+        (None, ('q', 'tag')),
+        ('Attributes', ('name', 'label', 'type')),
+        ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id')),
+    )
     type = forms.MultipleChoiceField(
     type = forms.MultipleChoiceField(
         choices=PowerOutletTypeChoices,
         choices=PowerOutletTypeChoices,
         required=False,
         required=False,
@@ -925,13 +929,13 @@ class PowerOutletFilterForm(DeviceComponentFilterForm):
 
 
 class InterfaceFilterForm(DeviceComponentFilterForm):
 class InterfaceFilterForm(DeviceComponentFilterForm):
     model = Interface
     model = Interface
-    field_groups = [
-        ['q', 'tag'],
-        ['name', 'label', 'kind', 'type', 'speed', 'duplex', 'enabled', 'mgmt_only'],
-        ['vrf_id', 'mac_address', 'wwn'],
-        ['rf_role', 'rf_channel', 'rf_channel_width', 'tx_power'],
-        ['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'],
-    ]
+    fieldsets = (
+        (None, ('q', 'tag')),
+        ('Attributes', ('name', 'label', 'kind', 'type', 'speed', 'duplex', 'enabled', 'mgmt_only')),
+        ('Addressing', ('vrf_id', 'mac_address', 'wwn')),
+        ('Wireless', ('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power')),
+        ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id')),
+    )
     kind = forms.MultipleChoiceField(
     kind = forms.MultipleChoiceField(
         choices=InterfaceKindChoices,
         choices=InterfaceKindChoices,
         required=False,
         required=False,
@@ -1008,11 +1012,11 @@ class InterfaceFilterForm(DeviceComponentFilterForm):
 
 
 
 
 class FrontPortFilterForm(DeviceComponentFilterForm):
 class FrontPortFilterForm(DeviceComponentFilterForm):
-    field_groups = [
-        ['q', 'tag'],
-        ['name', 'label', 'type', 'color'],
-        ['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'],
-    ]
+    fieldsets = (
+        (None, ('q', 'tag')),
+        ('Attributes', ('name', 'label', 'type', 'color')),
+        ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id')),
+    )
     model = FrontPort
     model = FrontPort
     type = forms.MultipleChoiceField(
     type = forms.MultipleChoiceField(
         choices=PortTypeChoices,
         choices=PortTypeChoices,
@@ -1027,11 +1031,11 @@ class FrontPortFilterForm(DeviceComponentFilterForm):
 
 
 class RearPortFilterForm(DeviceComponentFilterForm):
 class RearPortFilterForm(DeviceComponentFilterForm):
     model = RearPort
     model = RearPort
-    field_groups = [
-        ['q', 'tag'],
-        ['name', 'label', 'type', 'color'],
-        ['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'],
-    ]
+    fieldsets = (
+        (None, ('q', 'tag')),
+        ('Attributes', ('name', 'label', 'type', 'color')),
+        ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id')),
+    )
     type = forms.MultipleChoiceField(
     type = forms.MultipleChoiceField(
         choices=PortTypeChoices,
         choices=PortTypeChoices,
         required=False,
         required=False,
@@ -1045,11 +1049,11 @@ class RearPortFilterForm(DeviceComponentFilterForm):
 
 
 class ModuleBayFilterForm(DeviceComponentFilterForm):
 class ModuleBayFilterForm(DeviceComponentFilterForm):
     model = ModuleBay
     model = ModuleBay
-    field_groups = [
-        ['q', 'tag'],
-        ['name', 'label', 'position'],
-        ['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'],
-    ]
+    fieldsets = (
+        (None, ('q', 'tag')),
+        ('Attributes', ('name', 'label', 'position')),
+        ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id')),
+    )
     tag = TagFilterField(model)
     tag = TagFilterField(model)
     position = forms.CharField(
     position = forms.CharField(
         required=False
         required=False
@@ -1058,21 +1062,21 @@ class ModuleBayFilterForm(DeviceComponentFilterForm):
 
 
 class DeviceBayFilterForm(DeviceComponentFilterForm):
 class DeviceBayFilterForm(DeviceComponentFilterForm):
     model = DeviceBay
     model = DeviceBay
-    field_groups = [
-        ['q', 'tag'],
-        ['name', 'label'],
-        ['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'],
-    ]
+    fieldsets = (
+        (None, ('q', 'tag')),
+        ('Attributes', ('name', 'label')),
+        ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id')),
+    )
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
 class InventoryItemFilterForm(DeviceComponentFilterForm):
 class InventoryItemFilterForm(DeviceComponentFilterForm):
     model = InventoryItem
     model = InventoryItem
-    field_groups = [
-        ['q', 'tag'],
-        ['name', 'label', 'manufacturer_id', 'serial', 'asset_tag', 'discovered'],
-        ['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'],
-    ]
+    fieldsets = (
+        (None, ('q', 'tag')),
+        ('Attributes', ('name', 'label', 'manufacturer_id', 'serial', 'asset_tag', 'discovered')),
+        ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id')),
+    )
     role_id = DynamicModelMultipleChoiceField(
     role_id = DynamicModelMultipleChoiceField(
         queryset=InventoryItemRole.objects.all(),
         queryset=InventoryItemRole.objects.all(),
         required=False,
         required=False,
@@ -1103,7 +1107,7 @@ class InventoryItemFilterForm(DeviceComponentFilterForm):
 # Device component roles
 # Device component roles
 #
 #
 
 
-class InventoryItemRoleFilterForm(CustomFieldModelFilterForm):
+class InventoryItemRoleFilterForm(NetBoxModelFilterSetForm):
     model = InventoryItemRole
     model = InventoryItemRole
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 

+ 91 - 83
netbox/dcim/forms/models.py

@@ -7,9 +7,9 @@ from timezone_field import TimeZoneFormField
 from dcim.choices import *
 from dcim.choices import *
 from dcim.constants import *
 from dcim.constants import *
 from dcim.models import *
 from dcim.models import *
-from extras.forms import CustomFieldModelForm
 from extras.models import Tag
 from extras.models import Tag
 from ipam.models import ASN, IPAddress, VLAN, VLANGroup, VRF
 from ipam.models import ASN, IPAddress, VLAN, VLANGroup, VRF
+from netbox.forms import NetBoxModelForm
 from tenancy.forms import TenancyForm
 from tenancy.forms import TenancyForm
 from utilities.forms import (
 from utilities.forms import (
     APISelect, add_blank_choice, BootstrapMixin, ClearableFileInput, CommentField, ContentTypeChoiceField,
     APISelect, add_blank_choice, BootstrapMixin, ClearableFileInput, CommentField, ContentTypeChoiceField,
@@ -72,7 +72,7 @@ Tagged (All): Implies all VLANs are available (w/optional untagged VLAN)
 """
 """
 
 
 
 
-class RegionForm(CustomFieldModelForm):
+class RegionForm(NetBoxModelForm):
     parent = DynamicModelChoiceField(
     parent = DynamicModelChoiceField(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
         required=False
         required=False
@@ -90,7 +90,7 @@ class RegionForm(CustomFieldModelForm):
         )
         )
 
 
 
 
-class SiteGroupForm(CustomFieldModelForm):
+class SiteGroupForm(NetBoxModelForm):
     parent = DynamicModelChoiceField(
     parent = DynamicModelChoiceField(
         queryset=SiteGroup.objects.all(),
         queryset=SiteGroup.objects.all(),
         required=False
         required=False
@@ -108,7 +108,7 @@ class SiteGroupForm(CustomFieldModelForm):
         )
         )
 
 
 
 
-class SiteForm(TenancyForm, CustomFieldModelForm):
+class SiteForm(TenancyForm, NetBoxModelForm):
     region = DynamicModelChoiceField(
     region = DynamicModelChoiceField(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
         required=False
         required=False
@@ -134,19 +134,20 @@ class SiteForm(TenancyForm, CustomFieldModelForm):
         required=False
         required=False
     )
     )
 
 
+    fieldsets = (
+        ('Site', (
+            'name', 'slug', 'status', 'region', 'group', 'facility', 'asns', 'time_zone', 'description', 'tags',
+        )),
+        ('Tenancy', ('tenant_group', 'tenant')),
+        ('Contact Info', ('physical_address', 'shipping_address', 'latitude', 'longitude')),
+    )
+
     class Meta:
     class Meta:
         model = Site
         model = Site
         fields = (
         fields = (
             'name', 'slug', 'status', 'region', 'group', 'tenant_group', 'tenant', 'facility', 'asns', 'time_zone',
             'name', 'slug', 'status', 'region', 'group', 'tenant_group', 'tenant', 'facility', 'asns', 'time_zone',
             'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'comments', 'tags',
             'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'comments', 'tags',
         )
         )
-        fieldsets = (
-            ('Site', (
-                'name', 'slug', 'status', 'region', 'group', 'facility', 'asns', 'time_zone', 'description', 'tags',
-            )),
-            ('Tenancy', ('tenant_group', 'tenant')),
-            ('Contact Info', ('physical_address', 'shipping_address', 'latitude', 'longitude')),
-        )
         widgets = {
         widgets = {
             'physical_address': SmallTextarea(
             'physical_address': SmallTextarea(
                 attrs={
                 attrs={
@@ -173,7 +174,7 @@ class SiteForm(TenancyForm, CustomFieldModelForm):
         }
         }
 
 
 
 
-class LocationForm(TenancyForm, CustomFieldModelForm):
+class LocationForm(TenancyForm, NetBoxModelForm):
     region = DynamicModelChoiceField(
     region = DynamicModelChoiceField(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
         required=False,
         required=False,
@@ -208,20 +209,21 @@ class LocationForm(TenancyForm, CustomFieldModelForm):
         required=False
         required=False
     )
     )
 
 
+    fieldsets = (
+        ('Location', (
+            'region', 'site_group', 'site', 'parent', 'name', 'slug', 'description', 'tags',
+        )),
+        ('Tenancy', ('tenant_group', 'tenant')),
+    )
+
     class Meta:
     class Meta:
         model = Location
         model = Location
         fields = (
         fields = (
             'region', 'site_group', 'site', 'parent', 'name', 'slug', 'description', 'tenant_group', 'tenant', 'tags',
             'region', 'site_group', 'site', 'parent', 'name', 'slug', 'description', 'tenant_group', 'tenant', 'tags',
         )
         )
-        fieldsets = (
-            ('Location', (
-                'region', 'site_group', 'site', 'parent', 'name', 'slug', 'description', 'tags',
-            )),
-            ('Tenancy', ('tenant_group', 'tenant')),
-        )
 
 
 
 
-class RackRoleForm(CustomFieldModelForm):
+class RackRoleForm(NetBoxModelForm):
     slug = SlugField()
     slug = SlugField()
     tags = DynamicModelMultipleChoiceField(
     tags = DynamicModelMultipleChoiceField(
         queryset=Tag.objects.all(),
         queryset=Tag.objects.all(),
@@ -235,7 +237,7 @@ class RackRoleForm(CustomFieldModelForm):
         ]
         ]
 
 
 
 
-class RackForm(TenancyForm, CustomFieldModelForm):
+class RackForm(TenancyForm, NetBoxModelForm):
     region = DynamicModelChoiceField(
     region = DynamicModelChoiceField(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
         required=False,
         required=False,
@@ -295,7 +297,7 @@ class RackForm(TenancyForm, CustomFieldModelForm):
         }
         }
 
 
 
 
-class RackReservationForm(TenancyForm, CustomFieldModelForm):
+class RackReservationForm(TenancyForm, NetBoxModelForm):
     region = DynamicModelChoiceField(
     region = DynamicModelChoiceField(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
         required=False,
         required=False,
@@ -347,19 +349,20 @@ class RackReservationForm(TenancyForm, CustomFieldModelForm):
         required=False
         required=False
     )
     )
 
 
+    fieldsets = (
+        ('Reservation', ('region', 'site', 'location', 'rack', 'units', 'user', 'description', 'tags')),
+        ('Tenancy', ('tenant_group', 'tenant')),
+    )
+
     class Meta:
     class Meta:
         model = RackReservation
         model = RackReservation
         fields = [
         fields = [
             'region', 'site_group', 'site', 'location', 'rack', 'units', 'user', 'tenant_group', 'tenant',
             'region', 'site_group', 'site', 'location', 'rack', 'units', 'user', 'tenant_group', 'tenant',
             'description', 'tags',
             'description', 'tags',
         ]
         ]
-        fieldsets = (
-            ('Reservation', ('region', 'site', 'location', 'rack', 'units', 'user', 'description', 'tags')),
-            ('Tenancy', ('tenant_group', 'tenant')),
-        )
 
 
 
 
-class ManufacturerForm(CustomFieldModelForm):
+class ManufacturerForm(NetBoxModelForm):
     slug = SlugField()
     slug = SlugField()
     tags = DynamicModelMultipleChoiceField(
     tags = DynamicModelMultipleChoiceField(
         queryset=Tag.objects.all(),
         queryset=Tag.objects.all(),
@@ -373,7 +376,7 @@ class ManufacturerForm(CustomFieldModelForm):
         ]
         ]
 
 
 
 
-class DeviceTypeForm(CustomFieldModelForm):
+class DeviceTypeForm(NetBoxModelForm):
     manufacturer = DynamicModelChoiceField(
     manufacturer = DynamicModelChoiceField(
         queryset=Manufacturer.objects.all()
         queryset=Manufacturer.objects.all()
     )
     )
@@ -386,21 +389,22 @@ class DeviceTypeForm(CustomFieldModelForm):
         required=False
         required=False
     )
     )
 
 
+    fieldsets = (
+        ('Device Type', (
+            'manufacturer', 'model', 'slug', 'part_number', 'tags',
+        )),
+        ('Chassis', (
+            'u_height', 'is_full_depth', 'subdevice_role', 'airflow',
+        )),
+        ('Images', ('front_image', 'rear_image')),
+    )
+
     class Meta:
     class Meta:
         model = DeviceType
         model = DeviceType
         fields = [
         fields = [
             'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow',
             'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow',
             'front_image', 'rear_image', 'comments', 'tags',
             'front_image', 'rear_image', 'comments', 'tags',
         ]
         ]
-        fieldsets = (
-            ('Device Type', (
-                'manufacturer', 'model', 'slug', 'part_number', 'tags',
-            )),
-            ('Chassis', (
-                'u_height', 'is_full_depth', 'subdevice_role', 'airflow',
-            )),
-            ('Images', ('front_image', 'rear_image')),
-        )
         widgets = {
         widgets = {
             'subdevice_role': StaticSelect(),
             'subdevice_role': StaticSelect(),
             'front_image': ClearableFileInput(attrs={
             'front_image': ClearableFileInput(attrs={
@@ -412,7 +416,7 @@ class DeviceTypeForm(CustomFieldModelForm):
         }
         }
 
 
 
 
-class ModuleTypeForm(CustomFieldModelForm):
+class ModuleTypeForm(NetBoxModelForm):
     manufacturer = DynamicModelChoiceField(
     manufacturer = DynamicModelChoiceField(
         queryset=Manufacturer.objects.all()
         queryset=Manufacturer.objects.all()
     )
     )
@@ -429,7 +433,7 @@ class ModuleTypeForm(CustomFieldModelForm):
         ]
         ]
 
 
 
 
-class DeviceRoleForm(CustomFieldModelForm):
+class DeviceRoleForm(NetBoxModelForm):
     slug = SlugField()
     slug = SlugField()
     tags = DynamicModelMultipleChoiceField(
     tags = DynamicModelMultipleChoiceField(
         queryset=Tag.objects.all(),
         queryset=Tag.objects.all(),
@@ -443,7 +447,7 @@ class DeviceRoleForm(CustomFieldModelForm):
         ]
         ]
 
 
 
 
-class PlatformForm(CustomFieldModelForm):
+class PlatformForm(NetBoxModelForm):
     manufacturer = DynamicModelChoiceField(
     manufacturer = DynamicModelChoiceField(
         queryset=Manufacturer.objects.all(),
         queryset=Manufacturer.objects.all(),
         required=False
         required=False
@@ -466,7 +470,7 @@ class PlatformForm(CustomFieldModelForm):
         }
         }
 
 
 
 
-class DeviceForm(TenancyForm, CustomFieldModelForm):
+class DeviceForm(TenancyForm, NetBoxModelForm):
     region = DynamicModelChoiceField(
     region = DynamicModelChoiceField(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
         required=False,
         required=False,
@@ -648,7 +652,7 @@ class DeviceForm(TenancyForm, CustomFieldModelForm):
             self.fields['position'].widget.choices = [(position, f'U{position}')]
             self.fields['position'].widget.choices = [(position, f'U{position}')]
 
 
 
 
-class ModuleForm(CustomFieldModelForm):
+class ModuleForm(NetBoxModelForm):
     device = DynamicModelChoiceField(
     device = DynamicModelChoiceField(
         queryset=Device.objects.all(),
         queryset=Device.objects.all(),
         required=False,
         required=False,
@@ -688,7 +692,7 @@ class ModuleForm(CustomFieldModelForm):
         ]
         ]
 
 
 
 
-class CableForm(TenancyForm, CustomFieldModelForm):
+class CableForm(TenancyForm, NetBoxModelForm):
     tags = DynamicModelMultipleChoiceField(
     tags = DynamicModelMultipleChoiceField(
         queryset=Tag.objects.all(),
         queryset=Tag.objects.all(),
         required=False
         required=False
@@ -711,7 +715,7 @@ class CableForm(TenancyForm, CustomFieldModelForm):
         }
         }
 
 
 
 
-class PowerPanelForm(CustomFieldModelForm):
+class PowerPanelForm(NetBoxModelForm):
     region = DynamicModelChoiceField(
     region = DynamicModelChoiceField(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
         required=False,
         required=False,
@@ -745,17 +749,18 @@ class PowerPanelForm(CustomFieldModelForm):
         required=False
         required=False
     )
     )
 
 
+    fieldsets = (
+        ('Power Panel', ('region', 'site_group', 'site', 'location', 'name', 'tags')),
+    )
+
     class Meta:
     class Meta:
         model = PowerPanel
         model = PowerPanel
         fields = [
         fields = [
             'region', 'site_group', 'site', 'location', 'name', 'tags',
             'region', 'site_group', 'site', 'location', 'name', 'tags',
         ]
         ]
-        fieldsets = (
-            ('Power Panel', ('region', 'site_group', 'site', 'location', 'name', 'tags')),
-        )
 
 
 
 
-class PowerFeedForm(CustomFieldModelForm):
+class PowerFeedForm(NetBoxModelForm):
     region = DynamicModelChoiceField(
     region = DynamicModelChoiceField(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
         required=False,
         required=False,
@@ -800,17 +805,18 @@ class PowerFeedForm(CustomFieldModelForm):
         required=False
         required=False
     )
     )
 
 
+    fieldsets = (
+        ('Power Panel', ('region', 'site', 'power_panel')),
+        ('Power Feed', ('rack', 'name', 'status', 'type', 'mark_connected', 'tags')),
+        ('Characteristics', ('supply', 'voltage', 'amperage', 'phase', 'max_utilization')),
+    )
+
     class Meta:
     class Meta:
         model = PowerFeed
         model = PowerFeed
         fields = [
         fields = [
             'region', 'site_group', 'site', 'power_panel', 'rack', 'name', 'status', 'type', 'mark_connected', 'supply',
             'region', 'site_group', 'site', 'power_panel', 'rack', 'name', 'status', 'type', 'mark_connected', 'supply',
             'phase', 'voltage', 'amperage', 'max_utilization', 'comments', 'tags',
             '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 = {
         widgets = {
             'status': StaticSelect(),
             'status': StaticSelect(),
             'type': StaticSelect(),
             'type': StaticSelect(),
@@ -823,7 +829,7 @@ class PowerFeedForm(CustomFieldModelForm):
 # Virtual chassis
 # Virtual chassis
 #
 #
 
 
-class VirtualChassisForm(CustomFieldModelForm):
+class VirtualChassisForm(NetBoxModelForm):
     master = forms.ModelChoiceField(
     master = forms.ModelChoiceField(
         queryset=Device.objects.all(),
         queryset=Device.objects.all(),
         required=False,
         required=False,
@@ -1101,16 +1107,17 @@ class InventoryItemTemplateForm(BootstrapMixin, forms.ModelForm):
         widget=forms.HiddenInput
         widget=forms.HiddenInput
     )
     )
 
 
+    fieldsets = (
+        ('Inventory Item', ('device_type', 'parent', 'name', 'label', 'role', 'description')),
+        ('Hardware', ('manufacturer', 'part_id')),
+    )
+
     class Meta:
     class Meta:
         model = InventoryItemTemplate
         model = InventoryItemTemplate
         fields = [
         fields = [
             'device_type', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'description',
             'device_type', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'description',
             'component_type', 'component_id',
             'component_type', 'component_id',
         ]
         ]
-        fieldsets = (
-            ('Inventory Item', ('device_type', 'parent', 'name', 'label', 'role', 'description')),
-            ('Hardware', ('manufacturer', 'part_id')),
-        )
         widgets = {
         widgets = {
             'device_type': forms.HiddenInput(),
             'device_type': forms.HiddenInput(),
         }
         }
@@ -1120,7 +1127,7 @@ class InventoryItemTemplateForm(BootstrapMixin, forms.ModelForm):
 # Device components
 # Device components
 #
 #
 
 
-class ConsolePortForm(CustomFieldModelForm):
+class ConsolePortForm(NetBoxModelForm):
     tags = DynamicModelMultipleChoiceField(
     tags = DynamicModelMultipleChoiceField(
         queryset=Tag.objects.all(),
         queryset=Tag.objects.all(),
         required=False
         required=False
@@ -1138,7 +1145,7 @@ class ConsolePortForm(CustomFieldModelForm):
         }
         }
 
 
 
 
-class ConsoleServerPortForm(CustomFieldModelForm):
+class ConsoleServerPortForm(NetBoxModelForm):
     tags = DynamicModelMultipleChoiceField(
     tags = DynamicModelMultipleChoiceField(
         queryset=Tag.objects.all(),
         queryset=Tag.objects.all(),
         required=False
         required=False
@@ -1156,7 +1163,7 @@ class ConsoleServerPortForm(CustomFieldModelForm):
         }
         }
 
 
 
 
-class PowerPortForm(CustomFieldModelForm):
+class PowerPortForm(NetBoxModelForm):
     tags = DynamicModelMultipleChoiceField(
     tags = DynamicModelMultipleChoiceField(
         queryset=Tag.objects.all(),
         queryset=Tag.objects.all(),
         required=False
         required=False
@@ -1174,7 +1181,7 @@ class PowerPortForm(CustomFieldModelForm):
         }
         }
 
 
 
 
-class PowerOutletForm(CustomFieldModelForm):
+class PowerOutletForm(NetBoxModelForm):
     power_port = DynamicModelChoiceField(
     power_port = DynamicModelChoiceField(
         queryset=PowerPort.objects.all(),
         queryset=PowerPort.objects.all(),
         required=False,
         required=False,
@@ -1199,7 +1206,7 @@ class PowerOutletForm(CustomFieldModelForm):
         }
         }
 
 
 
 
-class InterfaceForm(InterfaceCommonForm, CustomFieldModelForm):
+class InterfaceForm(InterfaceCommonForm, NetBoxModelForm):
     parent = DynamicModelChoiceField(
     parent = DynamicModelChoiceField(
         queryset=Interface.objects.all(),
         queryset=Interface.objects.all(),
         required=False,
         required=False,
@@ -1271,6 +1278,17 @@ class InterfaceForm(InterfaceCommonForm, CustomFieldModelForm):
         required=False
         required=False
     )
     )
 
 
+    fieldsets = (
+        ('Interface', ('device', 'name', 'type', 'speed', 'duplex', 'label', 'description', 'tags')),
+        ('Addressing', ('vrf', 'mac_address', 'wwn')),
+        ('Operation', ('mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')),
+        ('Related Interfaces', ('parent', 'bridge', 'lag')),
+        ('802.1Q Switching', ('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans')),
+        ('Wireless', (
+            'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'wireless_lan_group', 'wireless_lans',
+        )),
+    )
+
     class Meta:
     class Meta:
         model = Interface
         model = Interface
         fields = [
         fields = [
@@ -1278,17 +1296,6 @@ class InterfaceForm(InterfaceCommonForm, CustomFieldModelForm):
             'mgmt_only', 'mark_connected', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency',
             'mgmt_only', 'mark_connected', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency',
             'rf_channel_width', 'tx_power', 'wireless_lans', 'untagged_vlan', 'tagged_vlans', 'vrf', 'tags',
             'rf_channel_width', 'tx_power', 'wireless_lans', 'untagged_vlan', 'tagged_vlans', 'vrf', 'tags',
         ]
         ]
-        fieldsets = (
-            ('Interface', ('device', 'name', 'type', 'speed', 'duplex', 'label', 'description', 'tags')),
-            ('Addressing', ('vrf', 'mac_address', 'wwn')),
-            ('Operation', ('mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')),
-            ('Related Interfaces', ('parent', 'bridge', 'lag')),
-            ('802.1Q Switching', ('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans')),
-            ('Wireless', (
-                'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'wireless_lan_group',
-                'wireless_lans',
-            )),
-        )
         widgets = {
         widgets = {
             'device': forms.HiddenInput(),
             'device': forms.HiddenInput(),
             'type': StaticSelect(),
             'type': StaticSelect(),
@@ -1308,7 +1315,7 @@ class InterfaceForm(InterfaceCommonForm, CustomFieldModelForm):
         }
         }
 
 
 
 
-class FrontPortForm(CustomFieldModelForm):
+class FrontPortForm(NetBoxModelForm):
     rear_port = DynamicModelChoiceField(
     rear_port = DynamicModelChoiceField(
         queryset=RearPort.objects.all(),
         queryset=RearPort.objects.all(),
         query_params={
         query_params={
@@ -1332,7 +1339,7 @@ class FrontPortForm(CustomFieldModelForm):
         }
         }
 
 
 
 
-class RearPortForm(CustomFieldModelForm):
+class RearPortForm(NetBoxModelForm):
     tags = DynamicModelMultipleChoiceField(
     tags = DynamicModelMultipleChoiceField(
         queryset=Tag.objects.all(),
         queryset=Tag.objects.all(),
         required=False
         required=False
@@ -1349,7 +1356,7 @@ class RearPortForm(CustomFieldModelForm):
         }
         }
 
 
 
 
-class ModuleBayForm(CustomFieldModelForm):
+class ModuleBayForm(NetBoxModelForm):
     tags = DynamicModelMultipleChoiceField(
     tags = DynamicModelMultipleChoiceField(
         queryset=Tag.objects.all(),
         queryset=Tag.objects.all(),
         required=False
         required=False
@@ -1365,7 +1372,7 @@ class ModuleBayForm(CustomFieldModelForm):
         }
         }
 
 
 
 
-class DeviceBayForm(CustomFieldModelForm):
+class DeviceBayForm(NetBoxModelForm):
     tags = DynamicModelMultipleChoiceField(
     tags = DynamicModelMultipleChoiceField(
         queryset=Tag.objects.all(),
         queryset=Tag.objects.all(),
         required=False
         required=False
@@ -1401,7 +1408,7 @@ class PopulateDeviceBayForm(BootstrapMixin, forms.Form):
         ).exclude(pk=device_bay.device.pk)
         ).exclude(pk=device_bay.device.pk)
 
 
 
 
-class InventoryItemForm(CustomFieldModelForm):
+class InventoryItemForm(NetBoxModelForm):
     parent = DynamicModelChoiceField(
     parent = DynamicModelChoiceField(
         queryset=InventoryItem.objects.all(),
         queryset=InventoryItem.objects.all(),
         required=False,
         required=False,
@@ -1432,16 +1439,17 @@ class InventoryItemForm(CustomFieldModelForm):
         required=False
         required=False
     )
     )
 
 
+    fieldsets = (
+        ('Inventory Item', ('device', 'parent', 'name', 'label', 'role', 'description', 'tags')),
+        ('Hardware', ('manufacturer', 'part_id', 'serial', 'asset_tag')),
+    )
+
     class Meta:
     class Meta:
         model = InventoryItem
         model = InventoryItem
         fields = [
         fields = [
             'device', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag',
             'device', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag',
             'description', 'component_type', 'component_id', 'tags',
             'description', 'component_type', 'component_id', 'tags',
         ]
         ]
-        fieldsets = (
-            ('Inventory Item', ('device', 'parent', 'name', 'label', 'role', 'description', 'tags')),
-            ('Hardware', ('manufacturer', 'part_id', 'serial', 'asset_tag')),
-        )
         widgets = {
         widgets = {
             'device': forms.HiddenInput(),
             'device': forms.HiddenInput(),
         }
         }
@@ -1451,7 +1459,7 @@ class InventoryItemForm(CustomFieldModelForm):
 # Device component roles
 # Device component roles
 #
 #
 
 
-class InventoryItemRoleForm(CustomFieldModelForm):
+class InventoryItemRoleForm(NetBoxModelForm):
     slug = SlugField()
     slug = SlugField()
     tags = DynamicModelMultipleChoiceField(
     tags = DynamicModelMultipleChoiceField(
         queryset=Tag.objects.all(),
         queryset=Tag.objects.all(),

+ 2 - 2
netbox/dcim/forms/object_create.py

@@ -1,8 +1,8 @@
 from django import forms
 from django import forms
 
 
 from dcim.models import *
 from dcim.models import *
-from extras.forms import CustomFieldModelForm
 from extras.models import Tag
 from extras.models import Tag
+from netbox.forms import NetBoxModelForm
 from utilities.forms import (
 from utilities.forms import (
     BootstrapMixin, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField,
     BootstrapMixin, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField,
 )
 )
@@ -149,7 +149,7 @@ class FrontPortCreateForm(DeviceComponentCreateForm):
         }
         }
 
 
 
 
-class VirtualChassisCreateForm(CustomFieldModelForm):
+class VirtualChassisCreateForm(NetBoxModelForm):
     region = DynamicModelChoiceField(
     region = DynamicModelChoiceField(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
         required=False,
         required=False,

+ 5 - 18
netbox/extras/forms/bulk_edit.py

@@ -33,8 +33,7 @@ class CustomFieldBulkEditForm(BulkEditForm):
         required=False
         required=False
     )
     )
 
 
-    class Meta:
-        nullable_fields = []
+    nullable_fields = ('description',)
 
 
 
 
 class CustomLinkBulkEditForm(BulkEditForm):
 class CustomLinkBulkEditForm(BulkEditForm):
@@ -64,9 +63,6 @@ class CustomLinkBulkEditForm(BulkEditForm):
         widget=StaticSelect()
         widget=StaticSelect()
     )
     )
 
 
-    class Meta:
-        nullable_fields = []
-
 
 
 class ExportTemplateBulkEditForm(BulkEditForm):
 class ExportTemplateBulkEditForm(BulkEditForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
@@ -95,8 +91,7 @@ class ExportTemplateBulkEditForm(BulkEditForm):
         widget=BulkEditNullBooleanSelect()
         widget=BulkEditNullBooleanSelect()
     )
     )
 
 
-    class Meta:
-        nullable_fields = ['description', 'mime_type', 'file_extension']
+    nullable_fields = ('description', 'mime_type', 'file_extension')
 
 
 
 
 class WebhookBulkEditForm(BulkEditForm):
 class WebhookBulkEditForm(BulkEditForm):
@@ -138,8 +133,7 @@ class WebhookBulkEditForm(BulkEditForm):
         required=False
         required=False
     )
     )
 
 
-    class Meta:
-        nullable_fields = ['secret', 'conditions', 'ca_file_path']
+    nullable_fields = ('secret', 'conditions', 'ca_file_path')
 
 
 
 
 class TagBulkEditForm(BulkEditForm):
 class TagBulkEditForm(BulkEditForm):
@@ -155,8 +149,7 @@ class TagBulkEditForm(BulkEditForm):
         required=False
         required=False
     )
     )
 
 
-    class Meta:
-        nullable_fields = ['description']
+    nullable_fields = ('description',)
 
 
 
 
 class ConfigContextBulkEditForm(BulkEditForm):
 class ConfigContextBulkEditForm(BulkEditForm):
@@ -177,10 +170,7 @@ class ConfigContextBulkEditForm(BulkEditForm):
         max_length=100
         max_length=100
     )
     )
 
 
-    class Meta:
-        nullable_fields = [
-            'description',
-        ]
+    nullable_fields = ('description',)
 
 
 
 
 class JournalEntryBulkEditForm(BulkEditForm):
 class JournalEntryBulkEditForm(BulkEditForm):
@@ -196,6 +186,3 @@ class JournalEntryBulkEditForm(BulkEditForm):
         required=False,
         required=False,
         widget=forms.Textarea()
         widget=forms.Textarea()
     )
     )
-
-    class Meta:
-        nullable_fields = []

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

@@ -1,16 +1,8 @@
-from django import forms
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
-from django.db.models import Q
 
 
-from extras.choices import *
 from extras.models import *
 from extras.models import *
-from utilities.forms import BootstrapMixin, BulkEditBaseForm, CSVModelForm
 
 
 __all__ = (
 __all__ = (
-    'CustomFieldModelCSVForm',
-    'CustomFieldModelBulkEditForm',
-    'CustomFieldModelFilterForm',
-    'CustomFieldModelForm',
     'CustomFieldsMixin',
     'CustomFieldsMixin',
 )
 )
 
 
@@ -50,76 +42,3 @@ class CustomFieldsMixin:
 
 
             # Annotate the field in the list of CustomField form fields
             # Annotate the field in the list of CustomField form fields
             self.custom_fields[field_name] = customfield
             self.custom_fields[field_name] = customfield
-
-
-class CustomFieldModelForm(BootstrapMixin, 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, customfield in self.custom_fields.items():
-            key = cf_name[3:]  # Strip "cf_" from field name
-            value = self.cleaned_data.get(cf_name)
-
-            # Convert "empty" values to null
-            if value in self.fields[cf_name].empty_values:
-                self.instance.custom_field_data[key] = None
-            else:
-                self.instance.custom_field_data[key] = customfield.serialize(value)
-
-        return super().clean()
-
-
-class CustomFieldModelCSVForm(CSVModelForm, CustomFieldModelForm):
-
-    def _get_form_field(self, customfield):
-        return customfield.to_form_field(for_csv_import=True)
-
-
-class CustomFieldModelBulkEditForm(BootstrapMixin, CustomFieldsMixin, BulkEditBaseForm):
-
-    def _get_form_field(self, customfield):
-        return customfield.to_form_field(set_initial=False, enforce_required=False)
-
-    def _append_customfield_fields(self):
-        """
-        Append form fields for all CustomFields assigned to this object type.
-        """
-        for customfield in self._get_custom_fields(self._get_content_type()):
-            # Annotate non-required custom fields as nullable
-            if not customfield.required:
-                self.nullable_fields.append(customfield.name)
-
-            self.fields[customfield.name] = self._get_form_field(customfield)
-
-            # Annotate the field in the list of CustomField form fields
-            self.custom_fields[customfield.name] = customfield
-
-
-class CustomFieldModelFilterForm(BootstrapMixin, CustomFieldsMixin, forms.Form):
-    q = forms.CharField(
-        required=False,
-        label='Search'
-    )
-
-    def _get_custom_fields(self, content_type):
-        return CustomField.objects.filter(content_types=content_type).exclude(
-            Q(filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED) |
-            Q(type=CustomFieldTypeChoices.TYPE_JSON)
-        )
-
-    def _get_form_field(self, customfield):
-        return customfield.to_form_field(set_initial=False, enforce_required=False)

+ 34 - 35
netbox/extras/forms/filtersets.py

@@ -28,11 +28,10 @@ __all__ = (
 
 
 
 
 class CustomFieldFilterForm(FilterForm):
 class CustomFieldFilterForm(FilterForm):
-    field_groups = [
-        ['q'],
-        ['type', 'content_types'],
-        ['weight', 'required'],
-    ]
+    fieldsets = (
+        (None, ('q',)),
+        ('Attributes', ('type', 'content_types', 'weight', 'required')),
+    )
     content_types = ContentTypeMultipleChoiceField(
     content_types = ContentTypeMultipleChoiceField(
         queryset=ContentType.objects.all(),
         queryset=ContentType.objects.all(),
         limit_choices_to=FeatureQuery('custom_fields'),
         limit_choices_to=FeatureQuery('custom_fields'),
@@ -56,10 +55,10 @@ class CustomFieldFilterForm(FilterForm):
 
 
 
 
 class CustomLinkFilterForm(FilterForm):
 class CustomLinkFilterForm(FilterForm):
-    field_groups = [
-        ['q'],
-        ['content_type', 'enabled', 'new_window', 'weight'],
-    ]
+    fieldsets = (
+        (None, ('q',)),
+        ('Attributes', ('content_type', 'enabled', 'new_window', 'weight')),
+    )
     content_type = ContentTypeChoiceField(
     content_type = ContentTypeChoiceField(
         queryset=ContentType.objects.all(),
         queryset=ContentType.objects.all(),
         limit_choices_to=FeatureQuery('custom_fields'),
         limit_choices_to=FeatureQuery('custom_fields'),
@@ -83,10 +82,10 @@ class CustomLinkFilterForm(FilterForm):
 
 
 
 
 class ExportTemplateFilterForm(FilterForm):
 class ExportTemplateFilterForm(FilterForm):
-    field_groups = [
-        ['q'],
-        ['content_type', 'mime_type', 'file_extension', 'as_attachment'],
-    ]
+    fieldsets = (
+        (None, ('q',)),
+        ('Attributes', ('content_type', 'mime_type', 'file_extension', 'as_attachment')),
+    )
     content_type = ContentTypeChoiceField(
     content_type = ContentTypeChoiceField(
         queryset=ContentType.objects.all(),
         queryset=ContentType.objects.all(),
         limit_choices_to=FeatureQuery('custom_fields'),
         limit_choices_to=FeatureQuery('custom_fields'),
@@ -108,11 +107,11 @@ class ExportTemplateFilterForm(FilterForm):
 
 
 
 
 class WebhookFilterForm(FilterForm):
 class WebhookFilterForm(FilterForm):
-    field_groups = [
-        ['q'],
-        ['content_types', 'http_method', 'enabled'],
-        ['type_create', 'type_update', 'type_delete'],
-    ]
+    fieldsets = (
+        (None, ('q',)),
+        ('Attributes', ('content_types', 'http_method', 'enabled')),
+        ('Events', ('type_create', 'type_update', 'type_delete')),
+    )
     content_types = ContentTypeMultipleChoiceField(
     content_types = ContentTypeMultipleChoiceField(
         queryset=ContentType.objects.all(),
         queryset=ContentType.objects.all(),
         limit_choices_to=FeatureQuery('custom_fields'),
         limit_choices_to=FeatureQuery('custom_fields'),
@@ -160,13 +159,13 @@ class TagFilterForm(FilterForm):
 
 
 
 
 class ConfigContextFilterForm(FilterForm):
 class ConfigContextFilterForm(FilterForm):
-    field_groups = [
-        ['q', 'tag'],
-        ['region_id', 'site_group_id', 'site_id'],
-        ['device_type_id', 'platform_id', 'role_id'],
-        ['cluster_type_id', 'cluster_group_id', 'cluster_id'],
-        ['tenant_group_id', 'tenant_id']
-    ]
+    fieldsets = (
+        (None, ('q', 'tag')),
+        ('Location', ('region_id', 'site_group_id', 'site_id')),
+        ('Device', ('device_type_id', 'platform_id', 'role_id')),
+        ('Cluster', ('cluster_type_id', 'cluster_group_id', 'cluster_id')),
+        ('Tenant', ('tenant_group_id', 'tenant_id'))
+    )
     region_id = DynamicModelMultipleChoiceField(
     region_id = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
         required=False,
         required=False,
@@ -243,11 +242,11 @@ class LocalConfigContextFilterForm(forms.Form):
 
 
 class JournalEntryFilterForm(FilterForm):
 class JournalEntryFilterForm(FilterForm):
     model = JournalEntry
     model = JournalEntry
-    field_groups = [
-        ['q'],
-        ['created_before', 'created_after', 'created_by_id'],
-        ['assigned_object_type_id', 'kind']
-    ]
+    fieldsets = (
+        (None, ('q',)),
+        ('Creation', ('created_before', 'created_after', 'created_by_id')),
+        ('Attributes', ('assigned_object_type_id', 'kind'))
+    )
     created_after = forms.DateTimeField(
     created_after = forms.DateTimeField(
         required=False,
         required=False,
         label=_('After'),
         label=_('After'),
@@ -283,11 +282,11 @@ class JournalEntryFilterForm(FilterForm):
 
 
 class ObjectChangeFilterForm(FilterForm):
 class ObjectChangeFilterForm(FilterForm):
     model = ObjectChange
     model = ObjectChange
-    field_groups = [
-        ['q'],
-        ['time_before', 'time_after', 'action'],
-        ['user_id', 'changed_object_type_id'],
-    ]
+    fieldsets = (
+        (None, ('q',)),
+        ('Time', ('time_before', 'time_after')),
+        ('Attributes', ('action', 'user_id', 'changed_object_type_id')),
+    )
     time_after = forms.DateTimeField(
     time_after = forms.DateTimeField(
         required=False,
         required=False,
         label=_('After'),
         label=_('After'),

+ 33 - 45
netbox/extras/forms/models.py

@@ -13,7 +13,6 @@ from utilities.forms import (
 from virtualization.models import Cluster, ClusterGroup, ClusterType
 from virtualization.models import Cluster, ClusterGroup, ClusterType
 
 
 __all__ = (
 __all__ = (
-    'AddRemoveTagsForm',
     'ConfigContextForm',
     'ConfigContextForm',
     'CustomFieldForm',
     'CustomFieldForm',
     'CustomLinkForm',
     'CustomLinkForm',
@@ -31,16 +30,17 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm):
         limit_choices_to=FeatureQuery('custom_fields')
         limit_choices_to=FeatureQuery('custom_fields')
     )
     )
 
 
+    fieldsets = (
+        ('Custom Field', ('name', 'label', 'type', 'object_type', 'weight', 'required', 'description')),
+        ('Assigned Models', ('content_types',)),
+        ('Behavior', ('filter_logic',)),
+        ('Values', ('default', 'choices')),
+        ('Validation', ('validation_minimum', 'validation_maximum', 'validation_regex')),
+    )
+
     class Meta:
     class Meta:
         model = CustomField
         model = CustomField
         fields = '__all__'
         fields = '__all__'
-        fieldsets = (
-            ('Custom Field', ('name', 'label', 'type', 'object_type', 'weight', 'required', 'description')),
-            ('Assigned Models', ('content_types',)),
-            ('Behavior', ('filter_logic',)),
-            ('Values', ('default', 'choices')),
-            ('Validation', ('validation_minimum', 'validation_maximum', 'validation_regex')),
-        )
         widgets = {
         widgets = {
             'type': StaticSelect(),
             'type': StaticSelect(),
             'filter_logic': StaticSelect(),
             'filter_logic': StaticSelect(),
@@ -53,13 +53,14 @@ class CustomLinkForm(BootstrapMixin, forms.ModelForm):
         limit_choices_to=FeatureQuery('custom_links')
         limit_choices_to=FeatureQuery('custom_links')
     )
     )
 
 
+    fieldsets = (
+        ('Custom Link', ('name', 'content_type', 'weight', 'group_name', 'button_class', 'enabled', 'new_window')),
+        ('Templates', ('link_text', 'link_url')),
+    )
+
     class Meta:
     class Meta:
         model = CustomLink
         model = CustomLink
         fields = '__all__'
         fields = '__all__'
-        fieldsets = (
-            ('Custom Link', ('name', 'content_type', 'weight', 'group_name', 'button_class', 'enabled', 'new_window')),
-            ('Templates', ('link_text', 'link_url')),
-        )
         widgets = {
         widgets = {
             'button_class': StaticSelect(),
             'button_class': StaticSelect(),
             'link_text': forms.Textarea(attrs={'class': 'font-monospace'}),
             'link_text': forms.Textarea(attrs={'class': 'font-monospace'}),
@@ -78,14 +79,15 @@ class ExportTemplateForm(BootstrapMixin, forms.ModelForm):
         limit_choices_to=FeatureQuery('export_templates')
         limit_choices_to=FeatureQuery('export_templates')
     )
     )
 
 
+    fieldsets = (
+        ('Export Template', ('name', 'content_type', 'description')),
+        ('Template', ('template_code',)),
+        ('Rendering', ('mime_type', 'file_extension', 'as_attachment')),
+    )
+
     class Meta:
     class Meta:
         model = ExportTemplate
         model = ExportTemplate
         fields = '__all__'
         fields = '__all__'
-        fieldsets = (
-            ('Export Template', ('name', 'content_type', 'description')),
-            ('Template', ('template_code',)),
-            ('Rendering', ('mime_type', 'file_extension', 'as_attachment')),
-        )
         widgets = {
         widgets = {
             'template_code': forms.Textarea(attrs={'class': 'font-monospace'}),
             'template_code': forms.Textarea(attrs={'class': 'font-monospace'}),
         }
         }
@@ -97,18 +99,19 @@ class WebhookForm(BootstrapMixin, forms.ModelForm):
         limit_choices_to=FeatureQuery('webhooks')
         limit_choices_to=FeatureQuery('webhooks')
     )
     )
 
 
+    fieldsets = (
+        ('Webhook', ('name', 'content_types', 'enabled')),
+        ('Events', ('type_create', 'type_update', 'type_delete')),
+        ('HTTP Request', (
+            'payload_url', 'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret',
+        )),
+        ('Conditions', ('conditions',)),
+        ('SSL', ('ssl_verification', 'ca_file_path')),
+    )
+
     class Meta:
     class Meta:
         model = Webhook
         model = Webhook
         fields = '__all__'
         fields = '__all__'
-        fieldsets = (
-            ('Webhook', ('name', 'content_types', 'enabled')),
-            ('Events', ('type_create', 'type_update', 'type_delete')),
-            ('HTTP Request', (
-                'payload_url', 'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret',
-            )),
-            ('Conditions', ('conditions',)),
-            ('SSL', ('ssl_verification', 'ca_file_path')),
-        )
         labels = {
         labels = {
             'type_create': 'Creations',
             'type_create': 'Creations',
             'type_update': 'Updates',
             'type_update': 'Updates',
@@ -124,30 +127,15 @@ class WebhookForm(BootstrapMixin, forms.ModelForm):
 class TagForm(BootstrapMixin, forms.ModelForm):
 class TagForm(BootstrapMixin, forms.ModelForm):
     slug = SlugField()
     slug = SlugField()
 
 
+    fieldsets = (
+        ('Tag', ('name', 'slug', 'color', 'description')),
+    )
+
     class Meta:
     class Meta:
         model = Tag
         model = Tag
         fields = [
         fields = [
             'name', 'slug', 'color', 'description'
             '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):
 class ConfigContextForm(BootstrapMixin, forms.ModelForm):

+ 35 - 64
netbox/ipam/forms/bulk_edit.py

@@ -1,11 +1,11 @@
 from django import forms
 from django import forms
 
 
 from dcim.models import Region, Site, SiteGroup
 from dcim.models import Region, Site, SiteGroup
-from extras.forms import AddRemoveTagsForm, CustomFieldModelBulkEditForm
 from ipam.choices import *
 from ipam.choices import *
 from ipam.constants import *
 from ipam.constants import *
 from ipam.models import *
 from ipam.models import *
 from ipam.models import ASN
 from ipam.models import ASN
+from netbox.forms import NetBoxModelBulkEditForm
 from tenancy.models import Tenant
 from tenancy.models import Tenant
 from utilities.forms import (
 from utilities.forms import (
     add_blank_choice, BulkEditNullBooleanSelect, DatePicker, DynamicModelChoiceField, NumericArrayField, StaticSelect,
     add_blank_choice, BulkEditNullBooleanSelect, DatePicker, DynamicModelChoiceField, NumericArrayField, StaticSelect,
@@ -30,7 +30,7 @@ __all__ = (
 )
 )
 
 
 
 
-class VRFBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class VRFBulkEditForm(NetBoxModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=VRF.objects.all(),
         queryset=VRF.objects.all(),
         widget=forms.MultipleHiddenInput()
         widget=forms.MultipleHiddenInput()
@@ -49,13 +49,10 @@ class VRFBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
         required=False
         required=False
     )
     )
 
 
-    class Meta:
-        nullable_fields = [
-            'tenant', 'description',
-        ]
+    nullable_fields = ('tenant', 'description')
 
 
 
 
-class RouteTargetBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class RouteTargetBulkEditForm(NetBoxModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=RouteTarget.objects.all(),
         queryset=RouteTarget.objects.all(),
         widget=forms.MultipleHiddenInput()
         widget=forms.MultipleHiddenInput()
@@ -69,13 +66,10 @@ class RouteTargetBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
         required=False
         required=False
     )
     )
 
 
-    class Meta:
-        nullable_fields = [
-            'tenant', 'description',
-        ]
+    nullable_fields = ('tenant', 'description')
 
 
 
 
-class RIRBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class RIRBulkEditForm(NetBoxModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=RIR.objects.all(),
         queryset=RIR.objects.all(),
         widget=forms.MultipleHiddenInput
         widget=forms.MultipleHiddenInput
@@ -89,11 +83,10 @@ class RIRBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
         required=False
         required=False
     )
     )
 
 
-    class Meta:
-        nullable_fields = ['is_private', 'description']
+    nullable_fields = ('is_private', 'description')
 
 
 
 
-class ASNBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class ASNBulkEditForm(NetBoxModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=ASN.objects.all(),
         queryset=ASN.objects.all(),
         widget=forms.MultipleHiddenInput()
         widget=forms.MultipleHiddenInput()
@@ -116,16 +109,10 @@ class ASNBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
         required=False
         required=False
     )
     )
 
 
-    class Meta:
-        nullable_fields = [
-            'date_added', 'description',
-        ]
-        widgets = {
-            'date_added': DatePicker(),
-        }
+    nullable_fields = ('date_added', 'description')
 
 
 
 
-class AggregateBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class AggregateBulkEditForm(NetBoxModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=Aggregate.objects.all(),
         queryset=Aggregate.objects.all(),
         widget=forms.MultipleHiddenInput()
         widget=forms.MultipleHiddenInput()
@@ -147,16 +134,10 @@ class AggregateBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
         required=False
         required=False
     )
     )
 
 
-    class Meta:
-        nullable_fields = [
-            'date_added', 'description',
-        ]
-        widgets = {
-            'date_added': DatePicker(),
-        }
+    nullable_fields = ('date_added', 'description')
 
 
 
 
-class RoleBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class RoleBulkEditForm(NetBoxModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=Role.objects.all(),
         queryset=Role.objects.all(),
         widget=forms.MultipleHiddenInput
         widget=forms.MultipleHiddenInput
@@ -169,11 +150,10 @@ class RoleBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
         required=False
         required=False
     )
     )
 
 
-    class Meta:
-        nullable_fields = ['description']
+    nullable_fields = ('description',)
 
 
 
 
-class PrefixBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class PrefixBulkEditForm(NetBoxModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=Prefix.objects.all(),
         queryset=Prefix.objects.all(),
         widget=forms.MultipleHiddenInput()
         widget=forms.MultipleHiddenInput()
@@ -232,13 +212,12 @@ class PrefixBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
         required=False
         required=False
     )
     )
 
 
-    class Meta:
-        nullable_fields = [
-            'site', 'vrf', 'tenant', 'role', 'description',
-        ]
+    nullable_fields = (
+        'site', 'vrf', 'tenant', 'role', 'description',
+    )
 
 
 
 
-class IPRangeBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class IPRangeBulkEditForm(NetBoxModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=IPRange.objects.all(),
         queryset=IPRange.objects.all(),
         widget=forms.MultipleHiddenInput()
         widget=forms.MultipleHiddenInput()
@@ -266,13 +245,12 @@ class IPRangeBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
         required=False
         required=False
     )
     )
 
 
-    class Meta:
-        nullable_fields = [
-            'vrf', 'tenant', 'role', 'description',
-        ]
+    nullable_fields = (
+        'vrf', 'tenant', 'role', 'description',
+    )
 
 
 
 
-class IPAddressBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class IPAddressBulkEditForm(NetBoxModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=IPAddress.objects.all(),
         queryset=IPAddress.objects.all(),
         widget=forms.MultipleHiddenInput()
         widget=forms.MultipleHiddenInput()
@@ -311,13 +289,12 @@ class IPAddressBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
         required=False
         required=False
     )
     )
 
 
-    class Meta:
-        nullable_fields = [
-            'vrf', 'role', 'tenant', 'dns_name', 'description',
-        ]
+    nullable_fields = (
+        'vrf', 'role', 'tenant', 'dns_name', 'description',
+    )
 
 
 
 
-class FHRPGroupBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class FHRPGroupBulkEditForm(NetBoxModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=FHRPGroup.objects.all(),
         queryset=FHRPGroup.objects.all(),
         widget=forms.MultipleHiddenInput()
         widget=forms.MultipleHiddenInput()
@@ -348,11 +325,10 @@ class FHRPGroupBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
         required=False
         required=False
     )
     )
 
 
-    class Meta:
-        nullable_fields = ['auth_type', 'auth_key', 'description']
+    nullable_fields = ('auth_type', 'auth_key', 'description')
 
 
 
 
-class VLANGroupBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class VLANGroupBulkEditForm(NetBoxModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=VLANGroup.objects.all(),
         queryset=VLANGroup.objects.all(),
         widget=forms.MultipleHiddenInput
         widget=forms.MultipleHiddenInput
@@ -378,11 +354,10 @@ class VLANGroupBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
         required=False
         required=False
     )
     )
 
 
-    class Meta:
-        nullable_fields = ['site', 'description']
+    nullable_fields = ('site', 'description')
 
 
 
 
-class VLANBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class VLANBulkEditForm(NetBoxModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=VLAN.objects.all(),
         queryset=VLAN.objects.all(),
         widget=forms.MultipleHiddenInput()
         widget=forms.MultipleHiddenInput()
@@ -428,13 +403,12 @@ class VLANBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
         required=False
         required=False
     )
     )
 
 
-    class Meta:
-        nullable_fields = [
-            'site', 'group', 'tenant', 'role', 'description',
-        ]
+    nullable_fields = (
+        'site', 'group', 'tenant', 'role', 'description',
+    )
 
 
 
 
-class ServiceTemplateBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class ServiceTemplateBulkEditForm(NetBoxModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=ServiceTemplate.objects.all(),
         queryset=ServiceTemplate.objects.all(),
         widget=forms.MultipleHiddenInput()
         widget=forms.MultipleHiddenInput()
@@ -456,10 +430,7 @@ class ServiceTemplateBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditFor
         required=False
         required=False
     )
     )
 
 
-    class Meta:
-        nullable_fields = [
-            'description',
-        ]
+    nullable_fields = ('description',)
 
 
 
 
 class ServiceBulkEditForm(ServiceTemplateBulkEditForm):
 class ServiceBulkEditForm(ServiceTemplateBulkEditForm):

+ 15 - 15
netbox/ipam/forms/bulk_import.py

@@ -2,10 +2,10 @@ from django import forms
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 
 
 from dcim.models import Device, Interface, Site
 from dcim.models import Device, Interface, Site
-from extras.forms import CustomFieldModelCSVForm
 from ipam.choices import *
 from ipam.choices import *
 from ipam.constants import *
 from ipam.constants import *
 from ipam.models import *
 from ipam.models import *
+from netbox.forms import NetBoxModelCSVForm
 from tenancy.models import Tenant
 from tenancy.models import Tenant
 from utilities.forms import CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, SlugField
 from utilities.forms import CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, SlugField
 from virtualization.models import VirtualMachine, VMInterface
 from virtualization.models import VirtualMachine, VMInterface
@@ -28,7 +28,7 @@ __all__ = (
 )
 )
 
 
 
 
-class VRFCSVForm(CustomFieldModelCSVForm):
+class VRFCSVForm(NetBoxModelCSVForm):
     tenant = CSVModelChoiceField(
     tenant = CSVModelChoiceField(
         queryset=Tenant.objects.all(),
         queryset=Tenant.objects.all(),
         required=False,
         required=False,
@@ -41,7 +41,7 @@ class VRFCSVForm(CustomFieldModelCSVForm):
         fields = ('name', 'rd', 'tenant', 'enforce_unique', 'description')
         fields = ('name', 'rd', 'tenant', 'enforce_unique', 'description')
 
 
 
 
-class RouteTargetCSVForm(CustomFieldModelCSVForm):
+class RouteTargetCSVForm(NetBoxModelCSVForm):
     tenant = CSVModelChoiceField(
     tenant = CSVModelChoiceField(
         queryset=Tenant.objects.all(),
         queryset=Tenant.objects.all(),
         required=False,
         required=False,
@@ -54,7 +54,7 @@ class RouteTargetCSVForm(CustomFieldModelCSVForm):
         fields = ('name', 'description', 'tenant')
         fields = ('name', 'description', 'tenant')
 
 
 
 
-class RIRCSVForm(CustomFieldModelCSVForm):
+class RIRCSVForm(NetBoxModelCSVForm):
     slug = SlugField()
     slug = SlugField()
 
 
     class Meta:
     class Meta:
@@ -65,7 +65,7 @@ class RIRCSVForm(CustomFieldModelCSVForm):
         }
         }
 
 
 
 
-class AggregateCSVForm(CustomFieldModelCSVForm):
+class AggregateCSVForm(NetBoxModelCSVForm):
     rir = CSVModelChoiceField(
     rir = CSVModelChoiceField(
         queryset=RIR.objects.all(),
         queryset=RIR.objects.all(),
         to_field_name='name',
         to_field_name='name',
@@ -83,7 +83,7 @@ class AggregateCSVForm(CustomFieldModelCSVForm):
         fields = ('prefix', 'rir', 'tenant', 'date_added', 'description')
         fields = ('prefix', 'rir', 'tenant', 'date_added', 'description')
 
 
 
 
-class ASNCSVForm(CustomFieldModelCSVForm):
+class ASNCSVForm(NetBoxModelCSVForm):
     rir = CSVModelChoiceField(
     rir = CSVModelChoiceField(
         queryset=RIR.objects.all(),
         queryset=RIR.objects.all(),
         to_field_name='name',
         to_field_name='name',
@@ -102,7 +102,7 @@ class ASNCSVForm(CustomFieldModelCSVForm):
         help_texts = {}
         help_texts = {}
 
 
 
 
-class RoleCSVForm(CustomFieldModelCSVForm):
+class RoleCSVForm(NetBoxModelCSVForm):
     slug = SlugField()
     slug = SlugField()
 
 
     class Meta:
     class Meta:
@@ -110,7 +110,7 @@ class RoleCSVForm(CustomFieldModelCSVForm):
         fields = ('name', 'slug', 'weight', 'description')
         fields = ('name', 'slug', 'weight', 'description')
 
 
 
 
-class PrefixCSVForm(CustomFieldModelCSVForm):
+class PrefixCSVForm(NetBoxModelCSVForm):
     vrf = CSVModelChoiceField(
     vrf = CSVModelChoiceField(
         queryset=VRF.objects.all(),
         queryset=VRF.objects.all(),
         to_field_name='name',
         to_field_name='name',
@@ -174,7 +174,7 @@ class PrefixCSVForm(CustomFieldModelCSVForm):
                 self.fields['vlan'].queryset = self.fields['vlan'].queryset.filter(**params)
                 self.fields['vlan'].queryset = self.fields['vlan'].queryset.filter(**params)
 
 
 
 
-class IPRangeCSVForm(CustomFieldModelCSVForm):
+class IPRangeCSVForm(NetBoxModelCSVForm):
     vrf = CSVModelChoiceField(
     vrf = CSVModelChoiceField(
         queryset=VRF.objects.all(),
         queryset=VRF.objects.all(),
         to_field_name='name',
         to_field_name='name',
@@ -205,7 +205,7 @@ class IPRangeCSVForm(CustomFieldModelCSVForm):
         )
         )
 
 
 
 
-class IPAddressCSVForm(CustomFieldModelCSVForm):
+class IPAddressCSVForm(NetBoxModelCSVForm):
     vrf = CSVModelChoiceField(
     vrf = CSVModelChoiceField(
         queryset=VRF.objects.all(),
         queryset=VRF.objects.all(),
         to_field_name='name',
         to_field_name='name',
@@ -312,7 +312,7 @@ class IPAddressCSVForm(CustomFieldModelCSVForm):
         return ipaddress
         return ipaddress
 
 
 
 
-class FHRPGroupCSVForm(CustomFieldModelCSVForm):
+class FHRPGroupCSVForm(NetBoxModelCSVForm):
     protocol = CSVChoiceField(
     protocol = CSVChoiceField(
         choices=FHRPGroupProtocolChoices
         choices=FHRPGroupProtocolChoices
     )
     )
@@ -326,7 +326,7 @@ class FHRPGroupCSVForm(CustomFieldModelCSVForm):
         fields = ('protocol', 'group_id', 'auth_type', 'auth_key', 'description')
         fields = ('protocol', 'group_id', 'auth_type', 'auth_key', 'description')
 
 
 
 
-class VLANGroupCSVForm(CustomFieldModelCSVForm):
+class VLANGroupCSVForm(NetBoxModelCSVForm):
     slug = SlugField()
     slug = SlugField()
     scope_type = CSVContentTypeField(
     scope_type = CSVContentTypeField(
         queryset=ContentType.objects.filter(model__in=VLANGROUP_SCOPE_TYPES),
         queryset=ContentType.objects.filter(model__in=VLANGROUP_SCOPE_TYPES),
@@ -354,7 +354,7 @@ class VLANGroupCSVForm(CustomFieldModelCSVForm):
         }
         }
 
 
 
 
-class VLANCSVForm(CustomFieldModelCSVForm):
+class VLANCSVForm(NetBoxModelCSVForm):
     site = CSVModelChoiceField(
     site = CSVModelChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         required=False,
         required=False,
@@ -393,7 +393,7 @@ class VLANCSVForm(CustomFieldModelCSVForm):
         }
         }
 
 
 
 
-class ServiceTemplateCSVForm(CustomFieldModelCSVForm):
+class ServiceTemplateCSVForm(NetBoxModelCSVForm):
     protocol = CSVChoiceField(
     protocol = CSVChoiceField(
         choices=ServiceProtocolChoices,
         choices=ServiceProtocolChoices,
         help_text='IP protocol'
         help_text='IP protocol'
@@ -404,7 +404,7 @@ class ServiceTemplateCSVForm(CustomFieldModelCSVForm):
         fields = ('name', 'protocol', 'ports', 'description')
         fields = ('name', 'protocol', 'ports', 'description')
 
 
 
 
-class ServiceCSVForm(CustomFieldModelCSVForm):
+class ServiceCSVForm(NetBoxModelCSVForm):
     device = CSVModelChoiceField(
     device = CSVModelChoiceField(
         queryset=Device.objects.all(),
         queryset=Device.objects.all(),
         required=False,
         required=False,

+ 71 - 71
netbox/ipam/forms/filtersets.py

@@ -2,11 +2,11 @@ from django import forms
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
 
 
 from dcim.models import Location, Rack, Region, Site, SiteGroup
 from dcim.models import Location, Rack, Region, Site, SiteGroup
-from extras.forms import CustomFieldModelFilterForm
 from ipam.choices import *
 from ipam.choices import *
 from ipam.constants import *
 from ipam.constants import *
 from ipam.models import *
 from ipam.models import *
 from ipam.models import ASN
 from ipam.models import ASN
+from netbox.forms import NetBoxModelFilterSetForm
 from tenancy.forms import TenancyFilterForm
 from tenancy.forms import TenancyFilterForm
 from utilities.forms import (
 from utilities.forms import (
     add_blank_choice, DynamicModelChoiceField, DynamicModelMultipleChoiceField, StaticSelect, StaticSelectMultiple,
     add_blank_choice, DynamicModelChoiceField, DynamicModelMultipleChoiceField, StaticSelect, StaticSelectMultiple,
@@ -39,13 +39,13 @@ IPADDRESS_MASK_LENGTH_CHOICES = add_blank_choice([
 ])
 ])
 
 
 
 
-class VRFFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
+class VRFFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
     model = VRF
     model = VRF
-    field_groups = [
-        ['q', 'tag'],
-        ['import_target_id', 'export_target_id'],
-        ['tenant_group_id', 'tenant_id'],
-    ]
+    fieldsets = (
+        (None, ('q', 'tag')),
+        ('Route Targets', ('import_target_id', 'export_target_id')),
+        ('Tenant', ('tenant_group_id', 'tenant_id')),
+    )
     import_target_id = DynamicModelMultipleChoiceField(
     import_target_id = DynamicModelMultipleChoiceField(
         queryset=RouteTarget.objects.all(),
         queryset=RouteTarget.objects.all(),
         required=False,
         required=False,
@@ -59,13 +59,13 @@ class VRFFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
-class RouteTargetFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
+class RouteTargetFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
     model = RouteTarget
     model = RouteTarget
-    field_groups = [
-        ['q', 'tag'],
-        ['importing_vrf_id', 'exporting_vrf_id'],
-        ['tenant_group_id', 'tenant_id'],
-    ]
+    fieldsets = (
+        (None, ('q', 'tag')),
+        ('VRF', ('importing_vrf_id', 'exporting_vrf_id')),
+        ('Tenant', ('tenant_group_id', 'tenant_id')),
+    )
     importing_vrf_id = DynamicModelMultipleChoiceField(
     importing_vrf_id = DynamicModelMultipleChoiceField(
         queryset=VRF.objects.all(),
         queryset=VRF.objects.all(),
         required=False,
         required=False,
@@ -79,7 +79,7 @@ class RouteTargetFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
-class RIRFilterForm(CustomFieldModelFilterForm):
+class RIRFilterForm(NetBoxModelFilterSetForm):
     model = RIR
     model = RIR
     is_private = forms.NullBooleanField(
     is_private = forms.NullBooleanField(
         required=False,
         required=False,
@@ -91,13 +91,13 @@ class RIRFilterForm(CustomFieldModelFilterForm):
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
-class AggregateFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
+class AggregateFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
     model = Aggregate
     model = Aggregate
-    field_groups = [
-        ['q', 'tag'],
-        ['family', 'rir_id'],
-        ['tenant_group_id', 'tenant_id']
-    ]
+    fieldsets = (
+        (None, ('q', 'tag')),
+        ('Attributes', ('family', 'rir_id')),
+        ('Tenant', ('tenant_group_id', 'tenant_id')),
+    )
     family = forms.ChoiceField(
     family = forms.ChoiceField(
         required=False,
         required=False,
         choices=add_blank_choice(IPAddressFamilyChoices),
         choices=add_blank_choice(IPAddressFamilyChoices),
@@ -112,14 +112,13 @@ class AggregateFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
-class ASNFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
+class ASNFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
     model = ASN
     model = ASN
-    field_groups = [
-        ['q'],
-        ['rir_id'],
-        ['tenant_group_id', 'tenant_id'],
-        ['site_id'],
-    ]
+    fieldsets = (
+        (None, ('q', 'tag')),
+        ('Assignment', ('rir_id', 'site_id')),
+        ('Tenant', ('tenant_group_id', 'tenant_id')),
+    )
     rir_id = DynamicModelMultipleChoiceField(
     rir_id = DynamicModelMultipleChoiceField(
         queryset=RIR.objects.all(),
         queryset=RIR.objects.all(),
         required=False,
         required=False,
@@ -130,22 +129,23 @@ class ASNFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
         required=False,
         required=False,
         label=_('Site')
         label=_('Site')
     )
     )
+    tag = TagFilterField(model)
 
 
 
 
-class RoleFilterForm(CustomFieldModelFilterForm):
+class RoleFilterForm(NetBoxModelFilterSetForm):
     model = Role
     model = Role
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
-class PrefixFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
+class PrefixFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
     model = Prefix
     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']
-    ]
+    fieldsets = (
+        (None, ('q', 'tag')),
+        ('Addressing', ('within_include', 'family', 'status', 'role_id', 'mask_length', 'is_pool', 'mark_utilized')),
+        ('VRF', ('vrf_id', 'present_in_vrf_id')),
+        ('Location', ('region_id', 'site_group_id', 'site_id')),
+        ('Tenant', ('tenant_group_id', 'tenant_id')),
+    )
     mask_length__lte = forms.IntegerField(
     mask_length__lte = forms.IntegerField(
         widget=forms.HiddenInput()
         widget=forms.HiddenInput()
     )
     )
@@ -228,13 +228,13 @@ class PrefixFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
-class IPRangeFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
+class IPRangeFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
     model = IPRange
     model = IPRange
-    field_groups = [
-        ['q', 'tag'],
-        ['family', 'vrf_id', 'status', 'role_id'],
-        ['tenant_group_id', 'tenant_id'],
-    ]
+    fieldsets = (
+        (None, ('q', 'tag')),
+        ('Attriubtes', ('family', 'vrf_id', 'status', 'role_id')),
+        ('Tenant', ('tenant_group_id', 'tenant_id')),
+    )
     family = forms.ChoiceField(
     family = forms.ChoiceField(
         required=False,
         required=False,
         choices=add_blank_choice(IPAddressFamilyChoices),
         choices=add_blank_choice(IPAddressFamilyChoices),
@@ -261,14 +261,14 @@ class IPRangeFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
-class IPAddressFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
+class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
     model = IPAddress
     model = IPAddress
-    field_groups = [
-        ['q', 'tag'],
-        ['parent', 'family', 'status', 'role', 'mask_length', 'assigned_to_interface'],
-        ['vrf_id', 'present_in_vrf_id'],
-        ['tenant_group_id', 'tenant_id'],
-    ]
+    fieldsets = (
+        (None, ('q', 'tag')),
+        ('Attributes', ('parent', 'family', 'status', 'role', 'mask_length', 'assigned_to_interface')),
+        ('VRF', ('vrf_id', 'present_in_vrf_id')),
+        ('Tenant', ('tenant_group_id', 'tenant_id')),
+    )
     parent = forms.CharField(
     parent = forms.CharField(
         required=False,
         required=False,
         widget=forms.TextInput(
         widget=forms.TextInput(
@@ -321,12 +321,12 @@ class IPAddressFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
-class FHRPGroupFilterForm(CustomFieldModelFilterForm):
+class FHRPGroupFilterForm(NetBoxModelFilterSetForm):
     model = FHRPGroup
     model = FHRPGroup
-    field_groups = (
-        ('q', 'tag'),
-        ('protocol', 'group_id'),
-        ('auth_type', 'auth_key'),
+    fieldsets = (
+        (None, ('q', 'tag')),
+        ('Attributes', ('protocol', 'group_id')),
+        ('Authentication', ('auth_type', 'auth_key')),
     )
     )
     protocol = forms.MultipleChoiceField(
     protocol = forms.MultipleChoiceField(
         choices=FHRPGroupProtocolChoices,
         choices=FHRPGroupProtocolChoices,
@@ -351,12 +351,12 @@ class FHRPGroupFilterForm(CustomFieldModelFilterForm):
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
-class VLANGroupFilterForm(CustomFieldModelFilterForm):
-    field_groups = [
-        ['q', 'tag'],
-        ['region', 'sitegroup', 'site', 'location', 'rack'],
-        ['min_vid', 'max_vid'],
-    ]
+class VLANGroupFilterForm(NetBoxModelFilterSetForm):
+    fieldsets = (
+        (None, ('q', 'tag')),
+        ('Location', ('region', 'sitegroup', 'site', 'location', 'rack')),
+        ('VLAN ID', ('min_vid', 'max_vid')),
+    )
     model = VLANGroup
     model = VLANGroup
     region = DynamicModelMultipleChoiceField(
     region = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
@@ -394,14 +394,14 @@ class VLANGroupFilterForm(CustomFieldModelFilterForm):
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
-class VLANFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
+class VLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
     model = VLAN
     model = VLAN
-    field_groups = [
-        ['q', 'tag'],
-        ['region_id', 'site_group_id', 'site_id'],
-        ['group_id', 'status', 'role_id', 'vid'],
-        ['tenant_group_id', 'tenant_id'],
-    ]
+    fieldsets = (
+        (None, ('q', 'tag')),
+        ('Location', ('region_id', 'site_group_id', 'site_id')),
+        ('Attributes', ('group_id', 'status', 'role_id', 'vid')),
+        ('Tenant', ('tenant_group_id', 'tenant_id')),
+    )
     region_id = DynamicModelMultipleChoiceField(
     region_id = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
         required=False,
         required=False,
@@ -448,11 +448,11 @@ class VLANFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
-class ServiceTemplateFilterForm(CustomFieldModelFilterForm):
+class ServiceTemplateFilterForm(NetBoxModelFilterSetForm):
     model = ServiceTemplate
     model = ServiceTemplate
-    field_groups = (
-        ('q', 'tag'),
-        ('protocol', 'port'),
+    fieldsets = (
+        (None, ('q', 'tag')),
+        ('Attributes', ('protocol', 'port')),
     )
     )
     protocol = forms.ChoiceField(
     protocol = forms.ChoiceField(
         choices=add_blank_choice(ServiceProtocolChoices),
         choices=add_blank_choice(ServiceProtocolChoices),

+ 60 - 52
netbox/ipam/forms/models.py

@@ -2,13 +2,13 @@ from django import forms
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 
 
 from dcim.models import Device, Interface, Location, Rack, Region, Site, SiteGroup
 from dcim.models import Device, Interface, Location, Rack, Region, Site, SiteGroup
-from extras.forms import CustomFieldModelForm
 from extras.models import Tag
 from extras.models import Tag
 from ipam.choices import *
 from ipam.choices import *
 from ipam.constants import *
 from ipam.constants import *
 from ipam.formfields import IPNetworkFormField
 from ipam.formfields import IPNetworkFormField
 from ipam.models import *
 from ipam.models import *
 from ipam.models import ASN
 from ipam.models import ASN
+from netbox.forms import NetBoxModelForm
 from tenancy.forms import TenancyForm
 from tenancy.forms import TenancyForm
 from utilities.exceptions import PermissionsViolation
 from utilities.exceptions import PermissionsViolation
 from utilities.forms import (
 from utilities.forms import (
@@ -39,7 +39,7 @@ __all__ = (
 )
 )
 
 
 
 
-class VRFForm(TenancyForm, CustomFieldModelForm):
+class VRFForm(TenancyForm, NetBoxModelForm):
     import_targets = DynamicModelMultipleChoiceField(
     import_targets = DynamicModelMultipleChoiceField(
         queryset=RouteTarget.objects.all(),
         queryset=RouteTarget.objects.all(),
         required=False
         required=False
@@ -53,17 +53,18 @@ class VRFForm(TenancyForm, CustomFieldModelForm):
         required=False
         required=False
     )
     )
 
 
+    fieldsets = (
+        ('VRF', ('name', 'rd', 'enforce_unique', 'description', 'tags')),
+        ('Route Targets', ('import_targets', 'export_targets')),
+        ('Tenancy', ('tenant_group', 'tenant')),
+    )
+
     class Meta:
     class Meta:
         model = VRF
         model = VRF
         fields = [
         fields = [
             'name', 'rd', 'enforce_unique', 'description', 'import_targets', 'export_targets', 'tenant_group', 'tenant',
             'name', 'rd', 'enforce_unique', 'description', 'import_targets', 'export_targets', 'tenant_group', 'tenant',
             'tags',
             'tags',
         ]
         ]
-        fieldsets = (
-            ('VRF', ('name', 'rd', 'enforce_unique', 'description', 'tags')),
-            ('Route Targets', ('import_targets', 'export_targets')),
-            ('Tenancy', ('tenant_group', 'tenant')),
-        )
         labels = {
         labels = {
             'rd': "RD",
             'rd': "RD",
         }
         }
@@ -72,24 +73,25 @@ class VRFForm(TenancyForm, CustomFieldModelForm):
         }
         }
 
 
 
 
-class RouteTargetForm(TenancyForm, CustomFieldModelForm):
+class RouteTargetForm(TenancyForm, NetBoxModelForm):
     tags = DynamicModelMultipleChoiceField(
     tags = DynamicModelMultipleChoiceField(
         queryset=Tag.objects.all(),
         queryset=Tag.objects.all(),
         required=False
         required=False
     )
     )
 
 
+    fieldsets = (
+        ('Route Target', ('name', 'description', 'tags')),
+        ('Tenancy', ('tenant_group', 'tenant')),
+    )
+
     class Meta:
     class Meta:
         model = RouteTarget
         model = RouteTarget
         fields = [
         fields = [
             'name', 'description', 'tenant_group', 'tenant', 'tags',
             'name', 'description', 'tenant_group', 'tenant', 'tags',
         ]
         ]
-        fieldsets = (
-            ('Route Target', ('name', 'description', 'tags')),
-            ('Tenancy', ('tenant_group', 'tenant')),
-        )
 
 
 
 
-class RIRForm(CustomFieldModelForm):
+class RIRForm(NetBoxModelForm):
     slug = SlugField()
     slug = SlugField()
     tags = DynamicModelMultipleChoiceField(
     tags = DynamicModelMultipleChoiceField(
         queryset=Tag.objects.all(),
         queryset=Tag.objects.all(),
@@ -103,7 +105,7 @@ class RIRForm(CustomFieldModelForm):
         ]
         ]
 
 
 
 
-class AggregateForm(TenancyForm, CustomFieldModelForm):
+class AggregateForm(TenancyForm, NetBoxModelForm):
     rir = DynamicModelChoiceField(
     rir = DynamicModelChoiceField(
         queryset=RIR.objects.all(),
         queryset=RIR.objects.all(),
         label='RIR'
         label='RIR'
@@ -113,15 +115,16 @@ class AggregateForm(TenancyForm, CustomFieldModelForm):
         required=False
         required=False
     )
     )
 
 
+    fieldsets = (
+        ('Aggregate', ('prefix', 'rir', 'date_added', 'description', 'tags')),
+        ('Tenancy', ('tenant_group', 'tenant')),
+    )
+
     class Meta:
     class Meta:
         model = Aggregate
         model = Aggregate
         fields = [
         fields = [
             'prefix', 'rir', 'date_added', 'description', 'tenant_group', 'tenant', 'tags',
             'prefix', 'rir', 'date_added', 'description', 'tenant_group', 'tenant', 'tags',
         ]
         ]
-        fieldsets = (
-            ('Aggregate', ('prefix', 'rir', 'date_added', 'description', 'tags')),
-            ('Tenancy', ('tenant_group', 'tenant')),
-        )
         help_texts = {
         help_texts = {
             'prefix': "IPv4 or IPv6 network",
             'prefix': "IPv4 or IPv6 network",
             'rir': "Regional Internet Registry responsible for this prefix",
             'rir': "Regional Internet Registry responsible for this prefix",
@@ -131,7 +134,7 @@ class AggregateForm(TenancyForm, CustomFieldModelForm):
         }
         }
 
 
 
 
-class ASNForm(TenancyForm, CustomFieldModelForm):
+class ASNForm(TenancyForm, NetBoxModelForm):
     rir = DynamicModelChoiceField(
     rir = DynamicModelChoiceField(
         queryset=RIR.objects.all(),
         queryset=RIR.objects.all(),
         label='RIR',
         label='RIR',
@@ -146,15 +149,16 @@ class ASNForm(TenancyForm, CustomFieldModelForm):
         required=False
         required=False
     )
     )
 
 
+    fieldsets = (
+        ('ASN', ('asn', 'rir', 'sites', 'description', 'tags')),
+        ('Tenancy', ('tenant_group', 'tenant')),
+    )
+
     class Meta:
     class Meta:
         model = ASN
         model = ASN
         fields = [
         fields = [
             'asn', 'rir', 'sites', 'tenant_group', 'tenant', 'description', 'tags'
             'asn', 'rir', 'sites', 'tenant_group', 'tenant', 'description', 'tags'
         ]
         ]
-        fieldsets = (
-            ('ASN', ('asn', 'rir', 'sites', 'description', 'tags')),
-            ('Tenancy', ('tenant_group', 'tenant')),
-        )
         help_texts = {
         help_texts = {
             'asn': "AS number",
             'asn': "AS number",
             'rir': "Regional Internet Registry responsible for this prefix",
             'rir': "Regional Internet Registry responsible for this prefix",
@@ -175,7 +179,7 @@ class ASNForm(TenancyForm, CustomFieldModelForm):
         return instance
         return instance
 
 
 
 
-class RoleForm(CustomFieldModelForm):
+class RoleForm(NetBoxModelForm):
     slug = SlugField()
     slug = SlugField()
     tags = DynamicModelMultipleChoiceField(
     tags = DynamicModelMultipleChoiceField(
         queryset=Tag.objects.all(),
         queryset=Tag.objects.all(),
@@ -189,7 +193,7 @@ class RoleForm(CustomFieldModelForm):
         ]
         ]
 
 
 
 
-class PrefixForm(TenancyForm, CustomFieldModelForm):
+class PrefixForm(TenancyForm, NetBoxModelForm):
     vrf = DynamicModelChoiceField(
     vrf = DynamicModelChoiceField(
         queryset=VRF.objects.all(),
         queryset=VRF.objects.all(),
         required=False,
         required=False,
@@ -248,23 +252,24 @@ class PrefixForm(TenancyForm, CustomFieldModelForm):
         required=False
         required=False
     )
     )
 
 
+    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')),
+    )
+
     class Meta:
     class Meta:
         model = Prefix
         model = Prefix
         fields = [
         fields = [
             'prefix', 'vrf', 'site', 'vlan', 'status', 'role', 'is_pool', 'mark_utilized', 'description',
             'prefix', 'vrf', 'site', 'vlan', 'status', 'role', 'is_pool', 'mark_utilized', 'description',
             'tenant_group', 'tenant', 'tags',
             '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 = {
         widgets = {
             'status': StaticSelect(),
             'status': StaticSelect(),
         }
         }
 
 
 
 
-class IPRangeForm(TenancyForm, CustomFieldModelForm):
+class IPRangeForm(TenancyForm, NetBoxModelForm):
     vrf = DynamicModelChoiceField(
     vrf = DynamicModelChoiceField(
         queryset=VRF.objects.all(),
         queryset=VRF.objects.all(),
         required=False,
         required=False,
@@ -279,21 +284,22 @@ class IPRangeForm(TenancyForm, CustomFieldModelForm):
         required=False
         required=False
     )
     )
 
 
+    fieldsets = (
+        ('IP Range', ('vrf', 'start_address', 'end_address', 'role', 'status', 'description', 'tags')),
+        ('Tenancy', ('tenant_group', 'tenant')),
+    )
+
     class Meta:
     class Meta:
         model = IPRange
         model = IPRange
         fields = [
         fields = [
             'vrf', 'start_address', 'end_address', 'status', 'role', 'description', 'tenant_group', 'tenant', 'tags',
             '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 = {
         widgets = {
             'status': StaticSelect(),
             'status': StaticSelect(),
         }
         }
 
 
 
 
-class IPAddressForm(TenancyForm, CustomFieldModelForm):
+class IPAddressForm(TenancyForm, NetBoxModelForm):
     device = DynamicModelChoiceField(
     device = DynamicModelChoiceField(
         queryset=Device.objects.all(),
         queryset=Device.objects.all(),
         required=False,
         required=False,
@@ -506,7 +512,7 @@ class IPAddressForm(TenancyForm, CustomFieldModelForm):
         return ipaddress
         return ipaddress
 
 
 
 
-class IPAddressBulkAddForm(TenancyForm, CustomFieldModelForm):
+class IPAddressBulkAddForm(TenancyForm, NetBoxModelForm):
     vrf = DynamicModelChoiceField(
     vrf = DynamicModelChoiceField(
         queryset=VRF.objects.all(),
         queryset=VRF.objects.all(),
         required=False,
         required=False,
@@ -540,7 +546,7 @@ class IPAddressAssignForm(BootstrapMixin, forms.Form):
     )
     )
 
 
 
 
-class FHRPGroupForm(CustomFieldModelForm):
+class FHRPGroupForm(NetBoxModelForm):
     tags = DynamicModelMultipleChoiceField(
     tags = DynamicModelMultipleChoiceField(
         queryset=Tag.objects.all(),
         queryset=Tag.objects.all(),
         required=False
         required=False
@@ -562,16 +568,17 @@ class FHRPGroupForm(CustomFieldModelForm):
         label='Status'
         label='Status'
     )
     )
 
 
+    fieldsets = (
+        ('FHRP Group', ('protocol', 'group_id', 'description', 'tags')),
+        ('Authentication', ('auth_type', 'auth_key')),
+        ('Virtual IP Address', ('ip_vrf', 'ip_address', 'ip_status'))
+    )
+
     class Meta:
     class Meta:
         model = FHRPGroup
         model = FHRPGroup
         fields = (
         fields = (
             'protocol', 'group_id', 'auth_type', 'auth_key', 'description', 'ip_vrf', 'ip_address', 'ip_status', 'tags',
             'protocol', 'group_id', 'auth_type', 'auth_key', 'description', 'ip_vrf', 'ip_address', 'ip_status', 'tags',
         )
         )
-        fieldsets = (
-            ('FHRP Group', ('protocol', 'group_id', 'description', 'tags')),
-            ('Authentication', ('auth_type', 'auth_key')),
-            ('Virtual IP Address', ('ip_vrf', 'ip_address', 'ip_status'))
-        )
 
 
     def save(self, *args, **kwargs):
     def save(self, *args, **kwargs):
         instance = super().save(*args, **kwargs)
         instance = super().save(*args, **kwargs)
@@ -629,7 +636,7 @@ class FHRPGroupAssignmentForm(BootstrapMixin, forms.ModelForm):
             self.fields['group'].widget.add_query_param('related_ip', ipaddress.pk)
             self.fields['group'].widget.add_query_param('related_ip', ipaddress.pk)
 
 
 
 
-class VLANGroupForm(CustomFieldModelForm):
+class VLANGroupForm(NetBoxModelForm):
     scope_type = ContentTypeChoiceField(
     scope_type = ContentTypeChoiceField(
         queryset=ContentType.objects.filter(model__in=VLANGROUP_SCOPE_TYPES),
         queryset=ContentType.objects.filter(model__in=VLANGROUP_SCOPE_TYPES),
         required=False
         required=False
@@ -699,17 +706,18 @@ class VLANGroupForm(CustomFieldModelForm):
         required=False
         required=False
     )
     )
 
 
+    fieldsets = (
+        ('VLAN Group', ('name', 'slug', 'description', 'tags')),
+        ('Child VLANs', ('min_vid', 'max_vid')),
+        ('Scope', ('scope_type', 'region', 'sitegroup', 'site', 'location', 'rack', 'clustergroup', 'cluster')),
+    )
+
     class Meta:
     class Meta:
         model = VLANGroup
         model = VLANGroup
         fields = [
         fields = [
             'name', 'slug', 'description', 'scope_type', 'region', 'sitegroup', 'site', 'location', 'rack',
             'name', 'slug', 'description', 'scope_type', 'region', 'sitegroup', 'site', 'location', 'rack',
             'clustergroup', 'cluster', 'min_vid', 'max_vid', 'tags',
             'clustergroup', 'cluster', 'min_vid', 'max_vid', 'tags',
         ]
         ]
-        fieldsets = (
-            ('VLAN Group', ('name', 'slug', 'description', 'tags')),
-            ('Child VLANs', ('min_vid', 'max_vid')),
-            ('Scope', ('scope_type', 'region', 'sitegroup', 'site', 'location', 'rack', 'clustergroup', 'cluster')),
-        )
         widgets = {
         widgets = {
             'scope_type': StaticSelect,
             'scope_type': StaticSelect,
         }
         }
@@ -736,7 +744,7 @@ class VLANGroupForm(CustomFieldModelForm):
             self.instance.scope_id = None
             self.instance.scope_id = None
 
 
 
 
-class VLANForm(TenancyForm, CustomFieldModelForm):
+class VLANForm(TenancyForm, NetBoxModelForm):
     # VLANGroup assignment fields
     # VLANGroup assignment fields
     scope_type = forms.ChoiceField(
     scope_type = forms.ChoiceField(
         choices=(
         choices=(
@@ -817,7 +825,7 @@ class VLANForm(TenancyForm, CustomFieldModelForm):
         }
         }
 
 
 
 
-class ServiceTemplateForm(CustomFieldModelForm):
+class ServiceTemplateForm(NetBoxModelForm):
     ports = NumericArrayField(
     ports = NumericArrayField(
         base_field=forms.IntegerField(
         base_field=forms.IntegerField(
             min_value=SERVICE_PORT_MIN,
             min_value=SERVICE_PORT_MIN,
@@ -838,7 +846,7 @@ class ServiceTemplateForm(CustomFieldModelForm):
         }
         }
 
 
 
 
-class ServiceForm(CustomFieldModelForm):
+class ServiceForm(NetBoxModelForm):
     device = DynamicModelChoiceField(
     device = DynamicModelChoiceField(
         queryset=Device.objects.all(),
         queryset=Device.objects.all(),
         required=False
         required=False

+ 1 - 0
netbox/netbox/forms.py → netbox/netbox/forms/__init__.py

@@ -1,6 +1,7 @@
 from django import forms
 from django import forms
 
 
 from utilities.forms import BootstrapMixin
 from utilities.forms import BootstrapMixin
+from .base import *
 
 
 OBJ_TYPE_CHOICES = (
 OBJ_TYPE_CHOICES = (
     ('', 'All Objects'),
     ('', 'All Objects'),

+ 126 - 0
netbox/netbox/forms/base.py

@@ -0,0 +1,126 @@
+from django import forms
+from django.contrib.contenttypes.models import ContentType
+from django.db.models import Q
+
+from extras.choices import CustomFieldFilterLogicChoices, CustomFieldTypeChoices
+from extras.forms.customfields import CustomFieldsMixin
+from extras.models import CustomField, Tag
+from utilities.forms import BootstrapMixin, BulkEditMixin, CSVModelForm
+from utilities.forms.fields import DynamicModelMultipleChoiceField
+
+__all__ = (
+    'NetBoxModelForm',
+    'NetBoxModelCSVForm',
+    'NetBoxModelBulkEditForm',
+    'NetBoxModelFilterSetForm',
+)
+
+
+class NetBoxModelForm(BootstrapMixin, CustomFieldsMixin, forms.ModelForm):
+    """
+    Base form for creating & editing NetBox models. Extends Django's ModelForm to add support for custom fields.
+
+    Attributes:
+        fieldsets: An iterable of two-tuples which define a heading and field set to display per section of
+            the rendered form (optional). If not defined, the all fields will be rendered as a single section.
+    """
+    fieldsets = ()
+
+    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, customfield in self.custom_fields.items():
+            key = cf_name[3:]  # Strip "cf_" from field name
+            value = self.cleaned_data.get(cf_name)
+
+            # Convert "empty" values to null
+            if value in self.fields[cf_name].empty_values:
+                self.instance.custom_field_data[key] = None
+            else:
+                self.instance.custom_field_data[key] = customfield.serialize(value)
+
+        return super().clean()
+
+
+class NetBoxModelCSVForm(CSVModelForm, NetBoxModelForm):
+    """
+    Base form for creating a NetBox objects from CSV data. Used for bulk importing.
+    """
+    def _get_form_field(self, customfield):
+        return customfield.to_form_field(for_csv_import=True)
+
+
+class NetBoxModelBulkEditForm(BootstrapMixin, CustomFieldsMixin, BulkEditMixin, forms.Form):
+    """
+    Base form for modifying multiple NetBox objects (of the same type) in bulk via the UI. Adds support for custom
+    fields and adding/removing tags.
+
+    Attributes:
+        nullable_fields: A list of field names indicating which fields support being set to null/empty
+    """
+    add_tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
+        required=False
+    )
+    remove_tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
+        required=False
+    )
+
+    def _get_form_field(self, customfield):
+        return customfield.to_form_field(set_initial=False, enforce_required=False)
+
+    def _append_customfield_fields(self):
+        """
+        Append form fields for all CustomFields assigned to this object type.
+        """
+        nullable_custom_fields = []
+        for customfield in self._get_custom_fields(self._get_content_type()):
+            # Record non-required custom fields as nullable
+            if not customfield.required:
+                nullable_custom_fields.append(customfield.name)
+
+            self.fields[customfield.name] = self._get_form_field(customfield)
+
+            # Annotate the field in the list of CustomField form fields
+            self.custom_fields[customfield.name] = customfield
+
+        # Annotate nullable custom fields (if any) on the form instance
+        if nullable_custom_fields:
+            self.custom_fields = (*self.custom_fields, *nullable_custom_fields)
+
+
+class NetBoxModelFilterSetForm(BootstrapMixin, CustomFieldsMixin, forms.Form):
+    """
+    Base form for FilerSet forms. These are used to filter object lists in the NetBox UI. Note that the
+    corresponding FilterSet *must* provide a `q` filter.
+
+    Attributes:
+        model: The model class associated with the form
+        fieldsets: An iterable of two-tuples which define a heading and field set to display per section of
+            the rendered form (optional). If not defined, the all fields will be rendered as a single section.
+    """
+    q = forms.CharField(
+        required=False,
+        label='Search'
+    )
+
+    def _get_custom_fields(self, content_type):
+        return CustomField.objects.filter(content_types=content_type).exclude(
+            Q(filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED) |
+            Q(type=CustomFieldTypeChoices.TYPE_JSON)
+        )
+
+    def _get_form_field(self, customfield):
+        return customfield.to_form_field(set_initial=False, enforce_required=False)

+ 3 - 7
netbox/netbox/views/generic/bulk_views.py

@@ -675,14 +675,13 @@ class BulkDeleteView(GetReturnURLMixin, BaseMultiObjectView):
     """
     """
     Delete objects in bulk.
     Delete objects in bulk.
 
 
-    filterset: FilterSet to apply when deleting by QuerySet
-    table: The table used to display devices being deleted
-    form: The form class used to delete objects in bulk
+    Attributes:
+        filterset: FilterSet to apply when deleting by QuerySet
+        table: The table used to display devices being deleted
     """
     """
     template_name = 'generic/object_bulk_delete.html'
     template_name = 'generic/object_bulk_delete.html'
     filterset = None
     filterset = None
     table = None
     table = None
-    form = None
 
 
     def get_required_permission(self):
     def get_required_permission(self):
         return get_permission_for_model(self.queryset.model, 'delete')
         return get_permission_for_model(self.queryset.model, 'delete')
@@ -694,9 +693,6 @@ class BulkDeleteView(GetReturnURLMixin, BaseMultiObjectView):
         class BulkDeleteForm(ConfirmationForm):
         class BulkDeleteForm(ConfirmationForm):
             pk = ModelMultipleChoiceField(queryset=self.queryset, widget=MultipleHiddenInput)
             pk = ModelMultipleChoiceField(queryset=self.queryset, widget=MultipleHiddenInput)
 
 
-        if self.form:
-            return self.form
-
         return BulkDeleteForm
         return BulkDeleteForm
 
 
     #
     #

+ 2 - 2
netbox/templates/generic/object_edit.html

@@ -33,7 +33,7 @@
         {% csrf_token %}
         {% csrf_token %}
 
 
         {% block form %}
         {% block form %}
-          {% if form.Meta.fieldsets %}
+          {% if form.fieldsets %}
 
 
             {# Render hidden fields #}
             {# Render hidden fields #}
             {% for field in form.hidden_fields %}
             {% for field in form.hidden_fields %}
@@ -41,7 +41,7 @@
             {% endfor %}
             {% endfor %}
 
 
             {# Render grouped fields according to Form #}
             {# Render grouped fields according to Form #}
-            {% for group, fields in form.Meta.fieldsets %}
+            {% for group, fields in form.fieldsets %}
               <div class="field-group mb-5">
               <div class="field-group mb-5">
                 <div class="row mb-2">
                 <div class="row mb-2">
                   <h5 class="offset-sm-3">{{ group }}</h5>
                   <h5 class="offset-sm-3">{{ group }}</h5>

+ 16 - 15
netbox/templates/inc/filter_list.html

@@ -7,21 +7,22 @@
       {% for field in filter_form.hidden_fields %}
       {% for field in filter_form.hidden_fields %}
         {{ field }}
         {{ field }}
       {% endfor %}
       {% endfor %}
-      {% if filter_form.field_groups %}
-        {# List filters by group #}
-        {% for group in filter_form.field_groups %}
-          <div class="col col-12">
-            {% for name in group %}
-              {% with field=filter_form|get_item:name %}
-                {% render_field field %}
-              {% endwith %}
-            {% endfor %}
-          </div>
-          {% if not forloop.last %}
-            <hr class="card-divider mt-0" />
+      {# List filters by group #}
+      {% for heading, fields in filter_form.fieldsets %}
+        <div class="col col-12">
+          {% if heading %}
+            <h6>{{ heading }}</h6>
           {% endif %}
           {% endif %}
-        {% endfor %}
-      {% else %}
+          {% for name in fields %}
+            {% with field=filter_form|get_item:name %}
+              {% render_field field %}
+            {% endwith %}
+          {% endfor %}
+        </div>
+        {% if not forloop.last %}
+          <hr class="card-divider mt-0" />
+        {% endif %}
+      {% empty %}
         {# List all non-customfield filters as declared in the form class #}
         {# List all non-customfield filters as declared in the form class #}
         {% for field in filter_form.visible_fields %}
         {% for field in filter_form.visible_fields %}
           {% if not filter_form.custom_fields or field.name not in filter_form.custom_fields %}
           {% if not filter_form.custom_fields or field.name not in filter_form.custom_fields %}
@@ -30,7 +31,7 @@
             </div>
             </div>
           {% endif %}
           {% endif %}
         {% endfor %}
         {% endfor %}
-      {% endif %}
+      {% endfor %}
       {% if filter_form.custom_fields %}
       {% if filter_form.custom_fields %}
         {# List all custom field filters #}
         {# List all custom field filters #}
         <hr class="card-divider mt-0" />
         <hr class="card-divider mt-0" />

+ 1 - 1
netbox/templates/users/preferences.html

@@ -8,7 +8,7 @@
   <form method="post" action="" id="preferences-update">
   <form method="post" action="" id="preferences-update">
     {% csrf_token %}
     {% csrf_token %}
 
 
-    {% for group, fields in form.Meta.fieldsets %}
+    {% for group, fields in form.fieldsets %}
       <div class="field-group my-5">
       <div class="field-group my-5">
         <div class="row mb-2">
         <div class="row mb-2">
           <h5 class="offset-sm-3">{{ group }}</h5>
           <h5 class="offset-sm-3">{{ group }}</h5>

+ 11 - 18
netbox/tenancy/forms/bulk_edit.py

@@ -1,6 +1,6 @@
 from django import forms
 from django import forms
 
 
-from extras.forms import AddRemoveTagsForm, CustomFieldModelBulkEditForm
+from netbox.forms import NetBoxModelBulkEditForm
 from tenancy.models import *
 from tenancy.models import *
 from utilities.forms import DynamicModelChoiceField
 from utilities.forms import DynamicModelChoiceField
 
 
@@ -17,7 +17,7 @@ __all__ = (
 # Tenants
 # Tenants
 #
 #
 
 
-class TenantGroupBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class TenantGroupBulkEditForm(NetBoxModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=TenantGroup.objects.all(),
         queryset=TenantGroup.objects.all(),
         widget=forms.MultipleHiddenInput
         widget=forms.MultipleHiddenInput
@@ -31,11 +31,10 @@ class TenantGroupBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
         required=False
         required=False
     )
     )
 
 
-    class Meta:
-        nullable_fields = ['parent', 'description']
+    nullable_fields = ('parent', 'description')
 
 
 
 
-class TenantBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class TenantBulkEditForm(NetBoxModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=Tenant.objects.all(),
         queryset=Tenant.objects.all(),
         widget=forms.MultipleHiddenInput()
         widget=forms.MultipleHiddenInput()
@@ -45,17 +44,14 @@ class TenantBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
         required=False
         required=False
     )
     )
 
 
-    class Meta:
-        nullable_fields = [
-            'group',
-        ]
+    nullable_fields = ('group',)
 
 
 
 
 #
 #
 # Contacts
 # Contacts
 #
 #
 
 
-class ContactGroupBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class ContactGroupBulkEditForm(NetBoxModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=ContactGroup.objects.all(),
         queryset=ContactGroup.objects.all(),
         widget=forms.MultipleHiddenInput
         widget=forms.MultipleHiddenInput
@@ -69,11 +65,10 @@ class ContactGroupBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
         required=False
         required=False
     )
     )
 
 
-    class Meta:
-        nullable_fields = ['parent', 'description']
+    nullable_fields = ('parent', 'description')
 
 
 
 
-class ContactRoleBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class ContactRoleBulkEditForm(NetBoxModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=ContactRole.objects.all(),
         queryset=ContactRole.objects.all(),
         widget=forms.MultipleHiddenInput
         widget=forms.MultipleHiddenInput
@@ -83,11 +78,10 @@ class ContactRoleBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
         required=False
         required=False
     )
     )
 
 
-    class Meta:
-        nullable_fields = ['description']
+    nullable_fields = ('description',)
 
 
 
 
-class ContactBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class ContactBulkEditForm(NetBoxModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=Contact.objects.all(),
         queryset=Contact.objects.all(),
         widget=forms.MultipleHiddenInput()
         widget=forms.MultipleHiddenInput()
@@ -112,5 +106,4 @@ class ContactBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
         required=False
         required=False
     )
     )
 
 
-    class Meta:
-        nullable_fields = ['group', 'title', 'phone', 'email', 'address', 'comments']
+    nullable_fields = ('group', 'title', 'phone', 'email', 'address', 'comments')

+ 6 - 6
netbox/tenancy/forms/bulk_import.py

@@ -1,4 +1,4 @@
-from extras.forms import CustomFieldModelCSVForm
+from netbox.forms import NetBoxModelCSVForm
 from tenancy.models import *
 from tenancy.models import *
 from utilities.forms import CSVModelChoiceField, SlugField
 from utilities.forms import CSVModelChoiceField, SlugField
 
 
@@ -15,7 +15,7 @@ __all__ = (
 # Tenants
 # Tenants
 #
 #
 
 
-class TenantGroupCSVForm(CustomFieldModelCSVForm):
+class TenantGroupCSVForm(NetBoxModelCSVForm):
     parent = CSVModelChoiceField(
     parent = CSVModelChoiceField(
         queryset=TenantGroup.objects.all(),
         queryset=TenantGroup.objects.all(),
         required=False,
         required=False,
@@ -29,7 +29,7 @@ class TenantGroupCSVForm(CustomFieldModelCSVForm):
         fields = ('name', 'slug', 'parent', 'description')
         fields = ('name', 'slug', 'parent', 'description')
 
 
 
 
-class TenantCSVForm(CustomFieldModelCSVForm):
+class TenantCSVForm(NetBoxModelCSVForm):
     slug = SlugField()
     slug = SlugField()
     group = CSVModelChoiceField(
     group = CSVModelChoiceField(
         queryset=TenantGroup.objects.all(),
         queryset=TenantGroup.objects.all(),
@@ -47,7 +47,7 @@ class TenantCSVForm(CustomFieldModelCSVForm):
 # Contacts
 # Contacts
 #
 #
 
 
-class ContactGroupCSVForm(CustomFieldModelCSVForm):
+class ContactGroupCSVForm(NetBoxModelCSVForm):
     parent = CSVModelChoiceField(
     parent = CSVModelChoiceField(
         queryset=ContactGroup.objects.all(),
         queryset=ContactGroup.objects.all(),
         required=False,
         required=False,
@@ -61,7 +61,7 @@ class ContactGroupCSVForm(CustomFieldModelCSVForm):
         fields = ('name', 'slug', 'parent', 'description')
         fields = ('name', 'slug', 'parent', 'description')
 
 
 
 
-class ContactRoleCSVForm(CustomFieldModelCSVForm):
+class ContactRoleCSVForm(NetBoxModelCSVForm):
     slug = SlugField()
     slug = SlugField()
 
 
     class Meta:
     class Meta:
@@ -69,7 +69,7 @@ class ContactRoleCSVForm(CustomFieldModelCSVForm):
         fields = ('name', 'slug', 'description')
         fields = ('name', 'slug', 'description')
 
 
 
 
-class ContactCSVForm(CustomFieldModelCSVForm):
+class ContactCSVForm(NetBoxModelCSVForm):
     group = CSVModelChoiceField(
     group = CSVModelChoiceField(
         queryset=ContactGroup.objects.all(),
         queryset=ContactGroup.objects.all(),
         required=False,
         required=False,

+ 6 - 14
netbox/tenancy/forms/filtersets.py

@@ -1,6 +1,6 @@
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
 
 
-from extras.forms import CustomFieldModelFilterForm
+from netbox.forms import NetBoxModelFilterSetForm
 from tenancy.models import *
 from tenancy.models import *
 from utilities.forms import DynamicModelMultipleChoiceField, TagFilterField
 from utilities.forms import DynamicModelMultipleChoiceField, TagFilterField
 
 
@@ -17,7 +17,7 @@ __all__ = (
 # Tenants
 # Tenants
 #
 #
 
 
-class TenantGroupFilterForm(CustomFieldModelFilterForm):
+class TenantGroupFilterForm(NetBoxModelFilterSetForm):
     model = TenantGroup
     model = TenantGroup
     parent_id = DynamicModelMultipleChoiceField(
     parent_id = DynamicModelMultipleChoiceField(
         queryset=TenantGroup.objects.all(),
         queryset=TenantGroup.objects.all(),
@@ -27,12 +27,8 @@ class TenantGroupFilterForm(CustomFieldModelFilterForm):
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
-class TenantFilterForm(CustomFieldModelFilterForm):
+class TenantFilterForm(NetBoxModelFilterSetForm):
     model = Tenant
     model = Tenant
-    field_groups = (
-        ('q', 'tag'),
-        ('group_id',),
-    )
     group_id = DynamicModelMultipleChoiceField(
     group_id = DynamicModelMultipleChoiceField(
         queryset=TenantGroup.objects.all(),
         queryset=TenantGroup.objects.all(),
         required=False,
         required=False,
@@ -46,7 +42,7 @@ class TenantFilterForm(CustomFieldModelFilterForm):
 # Contacts
 # Contacts
 #
 #
 
 
-class ContactGroupFilterForm(CustomFieldModelFilterForm):
+class ContactGroupFilterForm(NetBoxModelFilterSetForm):
     model = ContactGroup
     model = ContactGroup
     parent_id = DynamicModelMultipleChoiceField(
     parent_id = DynamicModelMultipleChoiceField(
         queryset=ContactGroup.objects.all(),
         queryset=ContactGroup.objects.all(),
@@ -56,17 +52,13 @@ class ContactGroupFilterForm(CustomFieldModelFilterForm):
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
-class ContactRoleFilterForm(CustomFieldModelFilterForm):
+class ContactRoleFilterForm(NetBoxModelFilterSetForm):
     model = ContactRole
     model = ContactRole
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
-class ContactFilterForm(CustomFieldModelFilterForm):
+class ContactFilterForm(NetBoxModelFilterSetForm):
     model = Contact
     model = Contact
-    field_groups = (
-        ('q', 'tag'),
-        ('group_id',),
-    )
     group_id = DynamicModelMultipleChoiceField(
     group_id = DynamicModelMultipleChoiceField(
         queryset=ContactGroup.objects.all(),
         queryset=ContactGroup.objects.all(),
         required=False,
         required=False,

+ 14 - 12
netbox/tenancy/forms/models.py

@@ -1,7 +1,7 @@
 from django import forms
 from django import forms
 
 
-from extras.forms import CustomFieldModelForm
 from extras.models import Tag
 from extras.models import Tag
+from netbox.forms import NetBoxModelForm
 from tenancy.models import *
 from tenancy.models import *
 from utilities.forms import (
 from utilities.forms import (
     BootstrapMixin, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField, SmallTextarea,
     BootstrapMixin, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField, SmallTextarea,
@@ -22,7 +22,7 @@ __all__ = (
 # Tenants
 # Tenants
 #
 #
 
 
-class TenantGroupForm(CustomFieldModelForm):
+class TenantGroupForm(NetBoxModelForm):
     parent = DynamicModelChoiceField(
     parent = DynamicModelChoiceField(
         queryset=TenantGroup.objects.all(),
         queryset=TenantGroup.objects.all(),
         required=False
         required=False
@@ -40,7 +40,7 @@ class TenantGroupForm(CustomFieldModelForm):
         ]
         ]
 
 
 
 
-class TenantForm(CustomFieldModelForm):
+class TenantForm(NetBoxModelForm):
     slug = SlugField()
     slug = SlugField()
     group = DynamicModelChoiceField(
     group = DynamicModelChoiceField(
         queryset=TenantGroup.objects.all(),
         queryset=TenantGroup.objects.all(),
@@ -52,21 +52,22 @@ class TenantForm(CustomFieldModelForm):
         required=False
         required=False
     )
     )
 
 
+    fieldsets = (
+        ('Tenant', ('name', 'slug', 'group', 'description', 'tags')),
+    )
+
     class Meta:
     class Meta:
         model = Tenant
         model = Tenant
         fields = (
         fields = (
             'name', 'slug', 'group', 'description', 'comments', 'tags',
             'name', 'slug', 'group', 'description', 'comments', 'tags',
         )
         )
-        fieldsets = (
-            ('Tenant', ('name', 'slug', 'group', 'description', 'tags')),
-        )
 
 
 
 
 #
 #
 # Contacts
 # Contacts
 #
 #
 
 
-class ContactGroupForm(CustomFieldModelForm):
+class ContactGroupForm(NetBoxModelForm):
     parent = DynamicModelChoiceField(
     parent = DynamicModelChoiceField(
         queryset=ContactGroup.objects.all(),
         queryset=ContactGroup.objects.all(),
         required=False
         required=False
@@ -82,7 +83,7 @@ class ContactGroupForm(CustomFieldModelForm):
         fields = ('parent', 'name', 'slug', 'description', 'tags')
         fields = ('parent', 'name', 'slug', 'description', 'tags')
 
 
 
 
-class ContactRoleForm(CustomFieldModelForm):
+class ContactRoleForm(NetBoxModelForm):
     slug = SlugField()
     slug = SlugField()
     tags = DynamicModelMultipleChoiceField(
     tags = DynamicModelMultipleChoiceField(
         queryset=Tag.objects.all(),
         queryset=Tag.objects.all(),
@@ -94,7 +95,7 @@ class ContactRoleForm(CustomFieldModelForm):
         fields = ('name', 'slug', 'description', 'tags')
         fields = ('name', 'slug', 'description', 'tags')
 
 
 
 
-class ContactForm(CustomFieldModelForm):
+class ContactForm(NetBoxModelForm):
     group = DynamicModelChoiceField(
     group = DynamicModelChoiceField(
         queryset=ContactGroup.objects.all(),
         queryset=ContactGroup.objects.all(),
         required=False
         required=False
@@ -105,14 +106,15 @@ class ContactForm(CustomFieldModelForm):
         required=False
         required=False
     )
     )
 
 
+    fieldsets = (
+        ('Contact', ('group', 'name', 'title', 'phone', 'email', 'address', 'tags')),
+    )
+
     class Meta:
     class Meta:
         model = Contact
         model = Contact
         fields = (
         fields = (
             'group', 'name', 'title', 'phone', 'email', 'address', 'comments', 'tags',
             'group', 'name', 'title', 'phone', 'email', 'address', 'comments', 'tags',
         )
         )
-        fieldsets = (
-            ('Contact', ('group', 'name', 'title', 'phone', 'email', 'address', 'tags')),
-        )
         widgets = {
         widgets = {
             'address': SmallTextarea(attrs={'rows': 3}),
             'address': SmallTextarea(attrs={'rows': 3}),
         }
         }

+ 10 - 10
netbox/users/forms.py

@@ -40,20 +40,20 @@ class UserConfigFormMetaclass(forms.models.ModelFormMetaclass):
 
 
 
 
 class UserConfigForm(BootstrapMixin, forms.ModelForm, metaclass=UserConfigFormMetaclass):
 class UserConfigForm(BootstrapMixin, forms.ModelForm, metaclass=UserConfigFormMetaclass):
+    fieldsets = (
+        ('User Interface', (
+            'pagination.per_page',
+            'pagination.placement',
+            'ui.colormode',
+        )),
+        ('Miscellaneous', (
+            'data_format',
+        )),
+    )
 
 
     class Meta:
     class Meta:
         model = UserConfig
         model = UserConfig
         fields = ()
         fields = ()
-        fieldsets = (
-            ('User Interface', (
-                'pagination.per_page',
-                'pagination.placement',
-                'ui.colormode',
-            )),
-            ('Miscellaneous', (
-                'data_format',
-            )),
-        )
 
 
     def __init__(self, *args, instance=None, **kwargs):
     def __init__(self, *args, instance=None, **kwargs):
 
 

+ 0 - 526
netbox/utilities/forms/fields.py

@@ -1,526 +0,0 @@
-import csv
-import json
-import re
-from io import StringIO
-from netaddr import AddrFormatError, EUI
-
-import django_filters
-from django import forms
-from django.conf import settings
-from django.contrib.contenttypes.models import ContentType
-from django.core.exceptions import MultipleObjectsReturned, ObjectDoesNotExist
-from django.db.models import Count, Q
-from django.forms import BoundField
-from django.forms.fields import JSONField as _JSONField, InvalidJSONInput
-from django.urls import reverse
-
-from utilities.choices import unpack_grouped_choices
-from utilities.utils import content_type_identifier, content_type_name
-from utilities.validators import EnhancedURLValidator
-from . import widgets
-from .constants import *
-from .utils import expand_alphanumeric_pattern, expand_ipaddress_pattern, parse_csv, validate_csv
-
-__all__ = (
-    'ColorField',
-    'CommentField',
-    'ContentTypeChoiceField',
-    'ContentTypeMultipleChoiceField',
-    'CSVChoiceField',
-    'CSVContentTypeField',
-    'CSVDataField',
-    'CSVFileField',
-    'CSVModelChoiceField',
-    'CSVMultipleChoiceField',
-    'CSVMultipleContentTypeField',
-    'CSVTypedChoiceField',
-    'DynamicModelChoiceField',
-    'DynamicModelMultipleChoiceField',
-    'ExpandableIPAddressField',
-    'ExpandableNameField',
-    'JSONField',
-    'LaxURLField',
-    'MACAddressField',
-    'SlugField',
-    'TagFilterField',
-)
-
-
-class CommentField(forms.CharField):
-    """
-    A textarea with support for Markdown rendering. Exists mostly just to add a standard help_text.
-    """
-    widget = forms.Textarea
-    default_label = ''
-    # TODO: Port Markdown cheat sheet to internal documentation
-    default_helptext = '<i class="mdi mdi-information-outline"></i> '\
-                       '<a href="https://github.com/adam-p/markdown-here/wiki/Markdown-Cheatsheet" target="_blank" tabindex="-1">'\
-                       'Markdown</a> syntax is supported'
-
-    def __init__(self, *args, **kwargs):
-        required = kwargs.pop('required', False)
-        label = kwargs.pop('label', self.default_label)
-        help_text = kwargs.pop('help_text', self.default_helptext)
-        super().__init__(required=required, label=label, help_text=help_text, *args, **kwargs)
-
-
-class SlugField(forms.SlugField):
-    """
-    Extend the built-in SlugField to automatically populate from a field called `name` unless otherwise specified.
-    """
-
-    def __init__(self, slug_source='name', *args, **kwargs):
-        label = kwargs.pop('label', "Slug")
-        help_text = kwargs.pop('help_text', "URL-friendly unique shorthand")
-        widget = kwargs.pop('widget', widgets.SlugWidget)
-        super().__init__(label=label, help_text=help_text, widget=widget, *args, **kwargs)
-        self.widget.attrs['slug-source'] = slug_source
-
-
-class ColorField(forms.CharField):
-    """
-    A field which represents a color in hexadecimal RRGGBB format.
-    """
-    widget = widgets.ColorSelect
-
-
-class TagFilterField(forms.MultipleChoiceField):
-    """
-    A filter field for the tags of a model. Only the tags used by a model are displayed.
-
-    :param model: The model of the filter
-    """
-    widget = widgets.StaticSelectMultiple
-
-    def __init__(self, model, *args, **kwargs):
-        def get_choices():
-            tags = model.tags.annotate(
-                count=Count('extras_taggeditem_items')
-            ).order_by('name')
-            return [
-                (str(tag.slug), '{} ({})'.format(tag.name, tag.count)) for tag in tags
-            ]
-
-        # Choices are fetched each time the form is initialized
-        super().__init__(label='Tags', choices=get_choices, required=False, *args, **kwargs)
-
-
-class LaxURLField(forms.URLField):
-    """
-    Modifies Django's built-in URLField to remove the requirement for fully-qualified domain names
-    (e.g. http://myserver/ is valid)
-    """
-    default_validators = [EnhancedURLValidator()]
-
-
-class JSONField(_JSONField):
-    """
-    Custom wrapper around Django's built-in JSONField to avoid presenting "null" as the default text.
-    """
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-        if not self.help_text:
-            self.help_text = 'Enter context data in <a href="https://json.org/">JSON</a> format.'
-            self.widget.attrs['placeholder'] = ''
-
-    def prepare_value(self, value):
-        if isinstance(value, InvalidJSONInput):
-            return value
-        if value is None:
-            return ''
-        return json.dumps(value, sort_keys=True, indent=4)
-
-
-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
-
-
-#
-# Content type fields
-#
-
-class ContentTypeChoiceMixin:
-
-    def __init__(self, queryset, *args, **kwargs):
-        # Order ContentTypes by app_label
-        queryset = queryset.order_by('app_label', 'model')
-        super().__init__(queryset, *args, **kwargs)
-
-    def label_from_instance(self, obj):
-        try:
-            return content_type_name(obj)
-        except AttributeError:
-            return super().label_from_instance(obj)
-
-
-class ContentTypeChoiceField(ContentTypeChoiceMixin, forms.ModelChoiceField):
-    widget = widgets.StaticSelect
-
-
-class ContentTypeMultipleChoiceField(ContentTypeChoiceMixin, forms.ModelMultipleChoiceField):
-    widget = widgets.StaticSelectMultiple
-
-
-#
-# CSV fields
-#
-
-class CSVDataField(forms.CharField):
-    """
-    A CharField (rendered as a Textarea) which accepts CSV-formatted data. It returns data as a two-tuple: The first
-    item is a dictionary of column headers, mapping field names to the attribute by which they match a related object
-    (where applicable). The second item is a list of dictionaries, each representing a discrete row of CSV data.
-
-    :param from_form: The form from which the field derives its validation rules.
-    """
-    widget = forms.Textarea
-
-    def __init__(self, from_form, *args, **kwargs):
-
-        form = from_form()
-        self.model = form.Meta.model
-        self.fields = form.fields
-        self.required_fields = [
-            name for name, field in form.fields.items() if field.required
-        ]
-
-        super().__init__(*args, **kwargs)
-
-        self.strip = False
-        if not self.label:
-            self.label = ''
-        if not self.initial:
-            self.initial = ','.join(self.required_fields) + '\n'
-        if not self.help_text:
-            self.help_text = 'Enter the list of column headers followed by one line per record to be imported, using ' \
-                             'commas to separate values. Multi-line data and values containing commas may be wrapped ' \
-                             'in double quotes.'
-
-    def to_python(self, value):
-        reader = csv.reader(StringIO(value.strip()))
-
-        return parse_csv(reader)
-
-    def validate(self, value):
-        headers, records = value
-        validate_csv(headers, self.fields, self.required_fields)
-
-        return value
-
-
-class CSVFileField(forms.FileField):
-    """
-    A FileField (rendered as a file input button) which accepts a file containing CSV-formatted data. It returns
-    data as a two-tuple: The first item is a dictionary of column headers, mapping field names to the attribute
-    by which they match a related object (where applicable). The second item is a list of dictionaries, each
-    representing a discrete row of CSV data.
-
-    :param from_form: The form from which the field derives its validation rules.
-    """
-
-    def __init__(self, from_form, *args, **kwargs):
-
-        form = from_form()
-        self.model = form.Meta.model
-        self.fields = form.fields
-        self.required_fields = [
-            name for name, field in form.fields.items() if field.required
-        ]
-
-        super().__init__(*args, **kwargs)
-
-    def to_python(self, file):
-        if file is None:
-            return None
-
-        csv_str = file.read().decode('utf-8').strip()
-        reader = csv.reader(StringIO(csv_str))
-        headers, records = parse_csv(reader)
-
-        return headers, records
-
-    def validate(self, value):
-        if value is None:
-            return None
-
-        headers, records = value
-        validate_csv(headers, self.fields, self.required_fields)
-
-        return value
-
-
-class CSVChoicesMixin:
-    STATIC_CHOICES = True
-
-    def __init__(self, *, choices=(), **kwargs):
-        super().__init__(choices=choices, **kwargs)
-        self.choices = unpack_grouped_choices(choices)
-
-
-class CSVChoiceField(CSVChoicesMixin, forms.ChoiceField):
-    """
-    A CSV field which accepts a single selection value.
-    """
-    pass
-
-
-class CSVMultipleChoiceField(CSVChoicesMixin, forms.MultipleChoiceField):
-    """
-    A CSV field which accepts multiple selection values.
-    """
-    def to_python(self, value):
-        if not value:
-            return []
-        if not isinstance(value, str):
-            raise forms.ValidationError(f"Invalid value for a multiple choice field: {value}")
-        return value.split(',')
-
-
-class CSVTypedChoiceField(forms.TypedChoiceField):
-    STATIC_CHOICES = True
-
-
-class CSVModelChoiceField(forms.ModelChoiceField):
-    """
-    Provides additional validation for model choices entered as CSV data.
-    """
-    default_error_messages = {
-        'invalid_choice': 'Object not found.',
-    }
-
-    def to_python(self, value):
-        try:
-            return super().to_python(value)
-        except MultipleObjectsReturned:
-            raise forms.ValidationError(
-                f'"{value}" is not a unique value for this field; multiple objects were found'
-            )
-
-
-class CSVContentTypeField(CSVModelChoiceField):
-    """
-    Reference a ContentType in the form <app>.<model>
-    """
-    STATIC_CHOICES = True
-
-    def prepare_value(self, value):
-        return content_type_identifier(value)
-
-    def to_python(self, value):
-        if not value:
-            return None
-        try:
-            app_label, model = value.split('.')
-        except ValueError:
-            raise forms.ValidationError(f'Object type must be specified as "<app>.<model>"')
-        try:
-            return self.queryset.get(app_label=app_label, model=model)
-        except ObjectDoesNotExist:
-            raise forms.ValidationError(f'Invalid object type')
-
-
-class CSVMultipleContentTypeField(forms.ModelMultipleChoiceField):
-    STATIC_CHOICES = True
-
-    # TODO: Improve validation of selected ContentTypes
-    def prepare_value(self, value):
-        if type(value) is str:
-            ct_filter = Q()
-            for name in value.split(','):
-                app_label, model = name.split('.')
-                ct_filter |= Q(app_label=app_label, model=model)
-            return list(ContentType.objects.filter(ct_filter).values_list('pk', flat=True))
-        return content_type_identifier(value)
-
-
-#
-# Expansion fields
-#
-
-class ExpandableNameField(forms.CharField):
-    """
-    A field which allows for numeric range expansion
-      Example: 'Gi0/[1-3]' => ['Gi0/1', 'Gi0/2', 'Gi0/3']
-    """
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-        if not self.help_text:
-            self.help_text = """
-                Alphanumeric ranges are supported for bulk creation. Mixed cases and types within a single range
-                are not supported. Example: <code>[ge,xe]-0/0/[0-9]</code>
-                """
-
-    def to_python(self, value):
-        if not value:
-            return ''
-        if re.search(ALPHANUMERIC_EXPANSION_PATTERN, value):
-            return list(expand_alphanumeric_pattern(value))
-        return [value]
-
-
-class ExpandableIPAddressField(forms.CharField):
-    """
-    A field which allows for expansion of IP address ranges
-      Example: '192.0.2.[1-254]/24' => ['192.0.2.1/24', '192.0.2.2/24', '192.0.2.3/24' ... '192.0.2.254/24']
-    """
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-        if not self.help_text:
-            self.help_text = 'Specify a numeric range to create multiple IPs.<br />'\
-                             'Example: <code>192.0.2.[1,5,100-254]/24</code>'
-
-    def to_python(self, value):
-        # Hackish address family detection but it's all we have to work with
-        if '.' in value and re.search(IP4_EXPANSION_PATTERN, value):
-            return list(expand_ipaddress_pattern(value, 4))
-        elif ':' in value and re.search(IP6_EXPANSION_PATTERN, value):
-            return list(expand_ipaddress_pattern(value, 6))
-        return [value]
-
-
-#
-# Dynamic fields
-#
-
-class DynamicModelChoiceMixin:
-    """
-    :param query_params: A dictionary of additional key/value pairs to attach to the API request
-    :param initial_params: A dictionary of child field references to use for selecting a parent field's initial value
-    :param null_option: The string used to represent a null selection (if any)
-    :param disabled_indicator: The name of the field which, if populated, will disable selection of the
-        choice (optional)
-    :param str fetch_trigger: The event type which will cause the select element to
-        fetch data from the API. Must be 'load', 'open', or 'collapse'. (optional)
-    """
-    filter = django_filters.ModelChoiceFilter
-    widget = widgets.APISelect
-
-    def __init__(self, query_params=None, initial_params=None, null_option=None, disabled_indicator=None,
-                 fetch_trigger=None, empty_label=None, *args, **kwargs):
-        self.query_params = query_params or {}
-        self.initial_params = initial_params or {}
-        self.null_option = null_option
-        self.disabled_indicator = disabled_indicator
-        self.fetch_trigger = fetch_trigger
-
-        # to_field_name is set by ModelChoiceField.__init__(), but we need to set it early for reference
-        # by widget_attrs()
-        self.to_field_name = kwargs.get('to_field_name')
-        self.empty_option = empty_label or ""
-
-        super().__init__(*args, **kwargs)
-
-    def widget_attrs(self, widget):
-        attrs = {
-            'data-empty-option': self.empty_option
-        }
-
-        # Set value-field attribute if the field specifies to_field_name
-        if self.to_field_name:
-            attrs['value-field'] = self.to_field_name
-
-        # Set the string used to represent a null option
-        if self.null_option is not None:
-            attrs['data-null-option'] = self.null_option
-
-        # Set the disabled indicator, if any
-        if self.disabled_indicator is not None:
-            attrs['disabled-indicator'] = self.disabled_indicator
-
-        # Set the fetch trigger, if any.
-        if self.fetch_trigger is not None:
-            attrs['data-fetch-trigger'] = self.fetch_trigger
-
-        # Attach any static query parameters
-        if (len(self.query_params) > 0):
-            widget.add_query_params(self.query_params)
-
-        return attrs
-
-    def get_bound_field(self, form, field_name):
-        bound_field = BoundField(form, self, field_name)
-
-        # Set initial value based on prescribed child fields (if not already set)
-        if not self.initial and self.initial_params:
-            filter_kwargs = {}
-            for kwarg, child_field in self.initial_params.items():
-                value = form.initial.get(child_field.lstrip('$'))
-                if value:
-                    filter_kwargs[kwarg] = value
-            if filter_kwargs:
-                self.initial = self.queryset.filter(**filter_kwargs).first()
-
-        # Modify the QuerySet of the field before we return it. Limit choices to any data already bound: Options
-        # will be populated on-demand via the APISelect widget.
-        data = bound_field.value()
-        if data:
-            field_name = getattr(self, 'to_field_name') or 'pk'
-            filter = self.filter(field_name=field_name)
-            try:
-                self.queryset = filter.filter(self.queryset, data)
-            except (TypeError, ValueError):
-                # Catch any error caused by invalid initial data passed from the user
-                self.queryset = self.queryset.none()
-        else:
-            self.queryset = self.queryset.none()
-
-        # Set the data URL on the APISelect widget (if not already set)
-        widget = bound_field.field.widget
-        if not widget.attrs.get('data-url'):
-            app_label = self.queryset.model._meta.app_label
-            model_name = self.queryset.model._meta.model_name
-            data_url = reverse('{}-api:{}-list'.format(app_label, model_name))
-            widget.attrs['data-url'] = data_url
-
-        return bound_field
-
-
-class DynamicModelChoiceField(DynamicModelChoiceMixin, forms.ModelChoiceField):
-    """
-    Override get_bound_field() to avoid pre-populating field choices with a SQL query. The field will be
-    rendered only with choices set via bound data. Choices are populated on-demand via the APISelect widget.
-    """
-
-    def clean(self, value):
-        """
-        When null option is enabled and "None" is sent as part of a form to be submitted, it is sent as the
-        string 'null'.  This will check for that condition and gracefully handle the conversion to a NoneType.
-        """
-        if self.null_option is not None and value == settings.FILTERS_NULL_CHOICE_VALUE:
-            return None
-        return super().clean(value)
-
-
-class DynamicModelMultipleChoiceField(DynamicModelChoiceMixin, forms.ModelMultipleChoiceField):
-    """
-    A multiple-choice version of DynamicModelChoiceField.
-    """
-    filter = django_filters.ModelMultipleChoiceFilter
-    widget = widgets.APISelectMultiple
-
-    def clean(self, value):
-        """
-        When null option is enabled and "None" is sent as part of a form to be submitted, it is sent as the
-        string 'null'.  This will check for that condition and gracefully handle the conversion to a NoneType.
-        """
-        if self.null_option is not None and settings.FILTERS_NULL_CHOICE_VALUE in value:
-            value = [v for v in value if v != settings.FILTERS_NULL_CHOICE_VALUE]
-            return [None, *value]
-        return super().clean(value)

+ 5 - 0
netbox/utilities/forms/fields/__init__.py

@@ -0,0 +1,5 @@
+from .content_types import *
+from .csv import *
+from .dynamic import *
+from .expandable import *
+from .fields import *

+ 37 - 0
netbox/utilities/forms/fields/content_types.py

@@ -0,0 +1,37 @@
+from django import forms
+
+from utilities.forms import widgets
+from utilities.utils import content_type_name
+
+__all__ = (
+    'ContentTypeChoiceField',
+    'ContentTypeMultipleChoiceField',
+)
+
+
+class ContentTypeChoiceMixin:
+
+    def __init__(self, queryset, *args, **kwargs):
+        # Order ContentTypes by app_label
+        queryset = queryset.order_by('app_label', 'model')
+        super().__init__(queryset, *args, **kwargs)
+
+    def label_from_instance(self, obj):
+        try:
+            return content_type_name(obj)
+        except AttributeError:
+            return super().label_from_instance(obj)
+
+
+class ContentTypeChoiceField(ContentTypeChoiceMixin, forms.ModelChoiceField):
+    """
+    Selection field for a single content type.
+    """
+    widget = widgets.StaticSelect
+
+
+class ContentTypeMultipleChoiceField(ContentTypeChoiceMixin, forms.ModelMultipleChoiceField):
+    """
+    Selection field for one or more content types.
+    """
+    widget = widgets.StaticSelectMultiple

+ 193 - 0
netbox/utilities/forms/fields/csv.py

@@ -0,0 +1,193 @@
+import csv
+from io import StringIO
+
+from django import forms
+from django.contrib.contenttypes.models import ContentType
+from django.core.exceptions import MultipleObjectsReturned, ObjectDoesNotExist
+from django.db.models import Q
+
+from utilities.choices import unpack_grouped_choices
+from utilities.forms.utils import parse_csv, validate_csv
+from utilities.utils import content_type_identifier
+
+__all__ = (
+    'CSVChoiceField',
+    'CSVContentTypeField',
+    'CSVDataField',
+    'CSVFileField',
+    'CSVModelChoiceField',
+    'CSVMultipleChoiceField',
+    'CSVMultipleContentTypeField',
+    'CSVTypedChoiceField',
+)
+
+
+class CSVDataField(forms.CharField):
+    """
+    A CharField (rendered as a Textarea) which accepts CSV-formatted data. It returns data as a two-tuple: The first
+    item is a dictionary of column headers, mapping field names to the attribute by which they match a related object
+    (where applicable). The second item is a list of dictionaries, each representing a discrete row of CSV data.
+
+    :param from_form: The form from which the field derives its validation rules.
+    """
+    widget = forms.Textarea
+
+    def __init__(self, from_form, *args, **kwargs):
+
+        form = from_form()
+        self.model = form.Meta.model
+        self.fields = form.fields
+        self.required_fields = [
+            name for name, field in form.fields.items() if field.required
+        ]
+
+        super().__init__(*args, **kwargs)
+
+        self.strip = False
+        if not self.label:
+            self.label = ''
+        if not self.initial:
+            self.initial = ','.join(self.required_fields) + '\n'
+        if not self.help_text:
+            self.help_text = 'Enter the list of column headers followed by one line per record to be imported, using ' \
+                             'commas to separate values. Multi-line data and values containing commas may be wrapped ' \
+                             'in double quotes.'
+
+    def to_python(self, value):
+        reader = csv.reader(StringIO(value.strip()))
+
+        return parse_csv(reader)
+
+    def validate(self, value):
+        headers, records = value
+        validate_csv(headers, self.fields, self.required_fields)
+
+        return value
+
+
+class CSVFileField(forms.FileField):
+    """
+    A FileField (rendered as a file input button) which accepts a file containing CSV-formatted data. It returns
+    data as a two-tuple: The first item is a dictionary of column headers, mapping field names to the attribute
+    by which they match a related object (where applicable). The second item is a list of dictionaries, each
+    representing a discrete row of CSV data.
+
+    :param from_form: The form from which the field derives its validation rules.
+    """
+
+    def __init__(self, from_form, *args, **kwargs):
+
+        form = from_form()
+        self.model = form.Meta.model
+        self.fields = form.fields
+        self.required_fields = [
+            name for name, field in form.fields.items() if field.required
+        ]
+
+        super().__init__(*args, **kwargs)
+
+    def to_python(self, file):
+        if file is None:
+            return None
+
+        csv_str = file.read().decode('utf-8').strip()
+        reader = csv.reader(StringIO(csv_str))
+        headers, records = parse_csv(reader)
+
+        return headers, records
+
+    def validate(self, value):
+        if value is None:
+            return None
+
+        headers, records = value
+        validate_csv(headers, self.fields, self.required_fields)
+
+        return value
+
+
+class CSVChoicesMixin:
+    STATIC_CHOICES = True
+
+    def __init__(self, *, choices=(), **kwargs):
+        super().__init__(choices=choices, **kwargs)
+        self.choices = unpack_grouped_choices(choices)
+
+
+class CSVChoiceField(CSVChoicesMixin, forms.ChoiceField):
+    """
+    A CSV field which accepts a single selection value.
+    """
+    pass
+
+
+class CSVMultipleChoiceField(CSVChoicesMixin, forms.MultipleChoiceField):
+    """
+    A CSV field which accepts multiple selection values.
+    """
+    def to_python(self, value):
+        if not value:
+            return []
+        if not isinstance(value, str):
+            raise forms.ValidationError(f"Invalid value for a multiple choice field: {value}")
+        return value.split(',')
+
+
+class CSVTypedChoiceField(forms.TypedChoiceField):
+    STATIC_CHOICES = True
+
+
+class CSVModelChoiceField(forms.ModelChoiceField):
+    """
+    Extends Django's `ModelChoiceField` to provide additional validation for CSV values.
+    """
+    default_error_messages = {
+        'invalid_choice': 'Object not found.',
+    }
+
+    def to_python(self, value):
+        try:
+            return super().to_python(value)
+        except MultipleObjectsReturned:
+            raise forms.ValidationError(
+                f'"{value}" is not a unique value for this field; multiple objects were found'
+            )
+
+
+class CSVContentTypeField(CSVModelChoiceField):
+    """
+    CSV field for referencing a single content type, in the form `<app>.<model>`.
+    """
+    STATIC_CHOICES = True
+
+    def prepare_value(self, value):
+        return content_type_identifier(value)
+
+    def to_python(self, value):
+        if not value:
+            return None
+        try:
+            app_label, model = value.split('.')
+        except ValueError:
+            raise forms.ValidationError(f'Object type must be specified as "<app>.<model>"')
+        try:
+            return self.queryset.get(app_label=app_label, model=model)
+        except ObjectDoesNotExist:
+            raise forms.ValidationError(f'Invalid object type')
+
+
+class CSVMultipleContentTypeField(forms.ModelMultipleChoiceField):
+    """
+    CSV field for referencing one or more content types, in the form `<app>.<model>`.
+    """
+    STATIC_CHOICES = True
+
+    # TODO: Improve validation of selected ContentTypes
+    def prepare_value(self, value):
+        if type(value) is str:
+            ct_filter = Q()
+            for name in value.split(','):
+                app_label, model = name.split('.')
+                ct_filter |= Q(app_label=app_label, model=model)
+            return list(ContentType.objects.filter(ct_filter).values_list('pk', flat=True))
+        return content_type_identifier(value)

+ 141 - 0
netbox/utilities/forms/fields/dynamic.py

@@ -0,0 +1,141 @@
+import django_filters
+from django import forms
+from django.conf import settings
+from django.forms import BoundField
+from django.urls import reverse
+
+from utilities.forms import widgets
+
+__all__ = (
+    'DynamicModelChoiceField',
+    'DynamicModelMultipleChoiceField',
+)
+
+
+class DynamicModelChoiceMixin:
+    """
+    Override `get_bound_field()` to avoid pre-populating field choices with a SQL query. The field will be
+    rendered only with choices set via bound data. Choices are populated on-demand via the APISelect widget.
+
+    Attributes:
+        query_params: A dictionary of additional key/value pairs to attach to the API request
+        initial_params: A dictionary of child field references to use for selecting a parent field's initial value
+        null_option: The string used to represent a null selection (if any)
+        disabled_indicator: The name of the field which, if populated, will disable selection of the
+            choice (optional)
+        fetch_trigger: The event type which will cause the select element to
+            fetch data from the API. Must be 'load', 'open', or 'collapse'. (optional)
+    """
+    filter = django_filters.ModelChoiceFilter
+    widget = widgets.APISelect
+
+    def __init__(self, query_params=None, initial_params=None, null_option=None, disabled_indicator=None,
+                 fetch_trigger=None, empty_label=None, *args, **kwargs):
+        self.query_params = query_params or {}
+        self.initial_params = initial_params or {}
+        self.null_option = null_option
+        self.disabled_indicator = disabled_indicator
+        self.fetch_trigger = fetch_trigger
+
+        # to_field_name is set by ModelChoiceField.__init__(), but we need to set it early for reference
+        # by widget_attrs()
+        self.to_field_name = kwargs.get('to_field_name')
+        self.empty_option = empty_label or ""
+
+        super().__init__(*args, **kwargs)
+
+    def widget_attrs(self, widget):
+        attrs = {
+            'data-empty-option': self.empty_option
+        }
+
+        # Set value-field attribute if the field specifies to_field_name
+        if self.to_field_name:
+            attrs['value-field'] = self.to_field_name
+
+        # Set the string used to represent a null option
+        if self.null_option is not None:
+            attrs['data-null-option'] = self.null_option
+
+        # Set the disabled indicator, if any
+        if self.disabled_indicator is not None:
+            attrs['disabled-indicator'] = self.disabled_indicator
+
+        # Set the fetch trigger, if any.
+        if self.fetch_trigger is not None:
+            attrs['data-fetch-trigger'] = self.fetch_trigger
+
+        # Attach any static query parameters
+        if (len(self.query_params) > 0):
+            widget.add_query_params(self.query_params)
+
+        return attrs
+
+    def get_bound_field(self, form, field_name):
+        bound_field = BoundField(form, self, field_name)
+
+        # Set initial value based on prescribed child fields (if not already set)
+        if not self.initial and self.initial_params:
+            filter_kwargs = {}
+            for kwarg, child_field in self.initial_params.items():
+                value = form.initial.get(child_field.lstrip('$'))
+                if value:
+                    filter_kwargs[kwarg] = value
+            if filter_kwargs:
+                self.initial = self.queryset.filter(**filter_kwargs).first()
+
+        # Modify the QuerySet of the field before we return it. Limit choices to any data already bound: Options
+        # will be populated on-demand via the APISelect widget.
+        data = bound_field.value()
+        if data:
+            field_name = getattr(self, 'to_field_name') or 'pk'
+            filter = self.filter(field_name=field_name)
+            try:
+                self.queryset = filter.filter(self.queryset, data)
+            except (TypeError, ValueError):
+                # Catch any error caused by invalid initial data passed from the user
+                self.queryset = self.queryset.none()
+        else:
+            self.queryset = self.queryset.none()
+
+        # Set the data URL on the APISelect widget (if not already set)
+        widget = bound_field.field.widget
+        if not widget.attrs.get('data-url'):
+            app_label = self.queryset.model._meta.app_label
+            model_name = self.queryset.model._meta.model_name
+            data_url = reverse('{}-api:{}-list'.format(app_label, model_name))
+            widget.attrs['data-url'] = data_url
+
+        return bound_field
+
+
+class DynamicModelChoiceField(DynamicModelChoiceMixin, forms.ModelChoiceField):
+    """
+    Dynamic selection field for a single object, backed by NetBox's REST API.
+    """
+    def clean(self, value):
+        """
+        When null option is enabled and "None" is sent as part of a form to be submitted, it is sent as the
+        string 'null'.  This will check for that condition and gracefully handle the conversion to a NoneType.
+        """
+        if self.null_option is not None and value == settings.FILTERS_NULL_CHOICE_VALUE:
+            return None
+        return super().clean(value)
+
+
+class DynamicModelMultipleChoiceField(DynamicModelChoiceMixin, forms.ModelMultipleChoiceField):
+    """
+    A multiple-choice version of `DynamicModelChoiceField`.
+    """
+    filter = django_filters.ModelMultipleChoiceFilter
+    widget = widgets.APISelectMultiple
+
+    def clean(self, value):
+        """
+        When null option is enabled and "None" is sent as part of a form to be submitted, it is sent as the
+        string 'null'.  This will check for that condition and gracefully handle the conversion to a NoneType.
+        """
+        if self.null_option is not None and settings.FILTERS_NULL_CHOICE_VALUE in value:
+            value = [v for v in value if v != settings.FILTERS_NULL_CHOICE_VALUE]
+            return [None, *value]
+        return super().clean(value)

+ 54 - 0
netbox/utilities/forms/fields/expandable.py

@@ -0,0 +1,54 @@
+import re
+
+from django import forms
+
+from utilities.forms.constants import *
+from utilities.forms.utils import expand_alphanumeric_pattern, expand_ipaddress_pattern
+
+__all__ = (
+    'ExpandableIPAddressField',
+    'ExpandableNameField',
+)
+
+
+class ExpandableNameField(forms.CharField):
+    """
+    A field which allows for numeric range expansion
+      Example: 'Gi0/[1-3]' => ['Gi0/1', 'Gi0/2', 'Gi0/3']
+    """
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        if not self.help_text:
+            self.help_text = """
+                Alphanumeric ranges are supported for bulk creation. Mixed cases and types within a single range
+                are not supported. Example: <code>[ge,xe]-0/0/[0-9]</code>
+                """
+
+    def to_python(self, value):
+        if not value:
+            return ''
+        if re.search(ALPHANUMERIC_EXPANSION_PATTERN, value):
+            return list(expand_alphanumeric_pattern(value))
+        return [value]
+
+
+class ExpandableIPAddressField(forms.CharField):
+    """
+    A field which allows for expansion of IP address ranges
+      Example: '192.0.2.[1-254]/24' => ['192.0.2.1/24', '192.0.2.2/24', '192.0.2.3/24' ... '192.0.2.254/24']
+    """
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        if not self.help_text:
+            self.help_text = 'Specify a numeric range to create multiple IPs.<br />'\
+                             'Example: <code>192.0.2.[1,5,100-254]/24</code>'
+
+    def to_python(self, value):
+        # Hackish address family detection but it's all we have to work with
+        if '.' in value and re.search(IP4_EXPANSION_PATTERN, value):
+            return list(expand_ipaddress_pattern(value, 4))
+        elif ':' in value and re.search(IP6_EXPANSION_PATTERN, value):
+            return list(expand_ipaddress_pattern(value, 6))
+        return [value]

+ 127 - 0
netbox/utilities/forms/fields/fields.py

@@ -0,0 +1,127 @@
+import json
+
+from django import forms
+from django.db.models import Count
+from django.forms.fields import JSONField as _JSONField, InvalidJSONInput
+from netaddr import AddrFormatError, EUI
+
+from utilities.forms import widgets
+from utilities.validators import EnhancedURLValidator
+
+__all__ = (
+    'ColorField',
+    'CommentField',
+    'JSONField',
+    'LaxURLField',
+    'MACAddressField',
+    'SlugField',
+    'TagFilterField',
+)
+
+
+class CommentField(forms.CharField):
+    """
+    A textarea with support for Markdown rendering. Exists mostly just to add a standard `help_text`.
+    """
+    widget = forms.Textarea
+    # TODO: Port Markdown cheat sheet to internal documentation
+    help_text = """
+        <i class="mdi mdi-information-outline"></i>
+        <a href="https://github.com/adam-p/markdown-here/wiki/Markdown-Cheatsheet" target="_blank" tabindex="-1">
+        Markdown</a> syntax is supported
+    """
+
+    def __init__(self, *, help_text=help_text, required=False, **kwargs):
+        super().__init__(help_text=help_text, required=required, **kwargs)
+
+
+class SlugField(forms.SlugField):
+    """
+    Extend Django's built-in SlugField to automatically populate from a field called `name` unless otherwise specified.
+
+    Parameters:
+        slug_source: Name of the form field from which the slug value will be derived
+    """
+    widget = widgets.SlugWidget
+    help_text = "URL-friendly unique shorthand"
+
+    def __init__(self, *, slug_source='name', help_text=help_text, **kwargs):
+        super().__init__(help_text=help_text, **kwargs)
+
+        self.widget.attrs['slug-source'] = slug_source
+
+
+class ColorField(forms.CharField):
+    """
+    A field which represents a color value in hexadecimal `RRGGBB` format. Utilizes NetBox's `ColorSelect` widget to
+    render choices.
+    """
+    widget = widgets.ColorSelect
+
+
+class TagFilterField(forms.MultipleChoiceField):
+    """
+    A filter field for the tags of a model. Only the tags used by a model are displayed.
+
+    :param model: The model of the filter
+    """
+    widget = widgets.StaticSelectMultiple
+
+    def __init__(self, model, *args, **kwargs):
+        def get_choices():
+            tags = model.tags.annotate(
+                count=Count('extras_taggeditem_items')
+            ).order_by('name')
+            return [
+                (str(tag.slug), '{} ({})'.format(tag.name, tag.count)) for tag in tags
+            ]
+
+        # Choices are fetched each time the form is initialized
+        super().__init__(label='Tags', choices=get_choices, required=False, *args, **kwargs)
+
+
+class LaxURLField(forms.URLField):
+    """
+    Modifies Django's built-in URLField to remove the requirement for fully-qualified domain names
+    (e.g. http://myserver/ is valid)
+    """
+    default_validators = [EnhancedURLValidator()]
+
+
+class JSONField(_JSONField):
+    """
+    Custom wrapper around Django's built-in JSONField to avoid presenting "null" as the default text.
+    """
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        if not self.help_text:
+            self.help_text = 'Enter context data in <a href="https://json.org/">JSON</a> format.'
+            self.widget.attrs['placeholder'] = ''
+
+    def prepare_value(self, value):
+        if isinstance(value, InvalidJSONInput):
+            return value
+        if value is None:
+            return ''
+        return json.dumps(value, sort_keys=True, indent=4)
+
+
+class MACAddressField(forms.Field):
+    """
+    Validates a 48-bit MAC address.
+    """
+    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

+ 21 - 16
netbox/utilities/forms/forms.py

@@ -10,7 +10,7 @@ from .widgets import APISelect, APISelectMultiple, ClearableFileInput, StaticSel
 __all__ = (
 __all__ = (
     'BootstrapMixin',
     'BootstrapMixin',
     'BulkEditForm',
     'BulkEditForm',
-    'BulkEditBaseForm',
+    'BulkEditMixin',
     'BulkRenameForm',
     'BulkRenameForm',
     'ConfirmationForm',
     'ConfirmationForm',
     'CSVModelForm',
     'CSVModelForm',
@@ -21,6 +21,10 @@ __all__ = (
 )
 )
 
 
 
 
+#
+# Mixins
+#
+
 class BootstrapMixin:
 class BootstrapMixin:
     """
     """
     Add the base Bootstrap CSS classes to form elements.
     Add the base Bootstrap CSS classes to form elements.
@@ -61,6 +65,21 @@ class BootstrapMixin:
                 field.widget.attrs['class'] = ' '.join((css, 'form-select')).strip()
                 field.widget.attrs['class'] = ' '.join((css, 'form-select')).strip()
 
 
 
 
+class BulkEditMixin:
+    """
+    Base form for editing multiple objects in bulk
+    """
+    nullable_fields = ()
+
+    def __init__(self, model, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.model = model
+
+
+#
+# Form classes
+#
+
 class ReturnURLForm(forms.Form):
 class ReturnURLForm(forms.Form):
     """
     """
     Provides a hidden return URL field to control where the user is directed after the form is submitted.
     Provides a hidden return URL field to control where the user is directed after the form is submitted.
@@ -75,21 +94,7 @@ class ConfirmationForm(BootstrapMixin, ReturnURLForm):
     confirm = forms.BooleanField(required=True, widget=forms.HiddenInput(), initial=True)
     confirm = forms.BooleanField(required=True, widget=forms.HiddenInput(), initial=True)
 
 
 
 
-class BulkEditBaseForm(forms.Form):
-    """
-    Base form for editing multiple objects in bulk
-    """
-    def __init__(self, model, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-        self.model = model
-        self.nullable_fields = []
-
-        # Copy any nullable fields defined in Meta
-        if hasattr(self.Meta, 'nullable_fields'):
-            self.nullable_fields = self.Meta.nullable_fields
-
-
-class BulkEditForm(BootstrapMixin, BulkEditBaseForm):
+class BulkEditForm(BootstrapMixin, BulkEditMixin, forms.Form):
     pass
     pass
 
 
 
 

+ 17 - 22
netbox/virtualization/forms/bulk_edit.py

@@ -3,8 +3,8 @@ from django import forms
 from dcim.choices import InterfaceModeChoices
 from dcim.choices import InterfaceModeChoices
 from dcim.constants import INTERFACE_MTU_MAX, INTERFACE_MTU_MIN
 from dcim.constants import INTERFACE_MTU_MAX, INTERFACE_MTU_MIN
 from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup
 from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup
-from extras.forms import AddRemoveTagsForm, CustomFieldModelBulkEditForm
 from ipam.models import VLAN
 from ipam.models import VLAN
+from netbox.forms import NetBoxModelBulkEditForm
 from tenancy.models import Tenant
 from tenancy.models import Tenant
 from utilities.forms import (
 from utilities.forms import (
     add_blank_choice, BulkEditNullBooleanSelect, BulkRenameForm, CommentField, DynamicModelChoiceField,
     add_blank_choice, BulkEditNullBooleanSelect, BulkRenameForm, CommentField, DynamicModelChoiceField,
@@ -23,7 +23,7 @@ __all__ = (
 )
 )
 
 
 
 
-class ClusterTypeBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class ClusterTypeBulkEditForm(NetBoxModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=ClusterType.objects.all(),
         queryset=ClusterType.objects.all(),
         widget=forms.MultipleHiddenInput
         widget=forms.MultipleHiddenInput
@@ -33,11 +33,10 @@ class ClusterTypeBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
         required=False
         required=False
     )
     )
 
 
-    class Meta:
-        nullable_fields = ['description']
+    nullable_fields = ('description',)
 
 
 
 
-class ClusterGroupBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class ClusterGroupBulkEditForm(NetBoxModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=ClusterGroup.objects.all(),
         queryset=ClusterGroup.objects.all(),
         widget=forms.MultipleHiddenInput
         widget=forms.MultipleHiddenInput
@@ -47,11 +46,10 @@ class ClusterGroupBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
         required=False
         required=False
     )
     )
 
 
-    class Meta:
-        nullable_fields = ['description']
+    nullable_fields = ('description',)
 
 
 
 
-class ClusterBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class ClusterBulkEditForm(NetBoxModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=Cluster.objects.all(),
         queryset=Cluster.objects.all(),
         widget=forms.MultipleHiddenInput()
         widget=forms.MultipleHiddenInput()
@@ -89,13 +87,12 @@ class ClusterBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
         label='Comments'
         label='Comments'
     )
     )
 
 
-    class Meta:
-        nullable_fields = [
-            'group', 'site', 'comments', 'tenant',
-        ]
+    nullable_fields = (
+        'group', 'site', 'comments', 'tenant',
+    )
 
 
 
 
-class VirtualMachineBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class VirtualMachineBulkEditForm(NetBoxModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=VirtualMachine.objects.all(),
         queryset=VirtualMachine.objects.all(),
         widget=forms.MultipleHiddenInput()
         widget=forms.MultipleHiddenInput()
@@ -144,13 +141,12 @@ class VirtualMachineBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm
         label='Comments'
         label='Comments'
     )
     )
 
 
-    class Meta:
-        nullable_fields = [
-            'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments',
-        ]
+    nullable_fields = (
+        'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments',
+    )
 
 
 
 
-class VMInterfaceBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class VMInterfaceBulkEditForm(NetBoxModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=VMInterface.objects.all(),
         queryset=VMInterface.objects.all(),
         widget=forms.MultipleHiddenInput()
         widget=forms.MultipleHiddenInput()
@@ -197,10 +193,9 @@ class VMInterfaceBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
         required=False
         required=False
     )
     )
 
 
-    class Meta:
-        nullable_fields = [
-            'parent', 'bridge', 'mtu', 'description',
-        ]
+    nullable_fields = (
+        'parent', 'bridge', 'mtu', 'description',
+    )
 
 
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
         super().__init__(*args, **kwargs)

+ 6 - 6
netbox/virtualization/forms/bulk_import.py

@@ -1,6 +1,6 @@
 from dcim.choices import InterfaceModeChoices
 from dcim.choices import InterfaceModeChoices
 from dcim.models import DeviceRole, Platform, Site
 from dcim.models import DeviceRole, Platform, Site
-from extras.forms import CustomFieldModelCSVForm
+from netbox.forms import NetBoxModelCSVForm
 from tenancy.models import Tenant
 from tenancy.models import Tenant
 from utilities.forms import CSVChoiceField, CSVModelChoiceField, SlugField
 from utilities.forms import CSVChoiceField, CSVModelChoiceField, SlugField
 from virtualization.choices import *
 from virtualization.choices import *
@@ -15,7 +15,7 @@ __all__ = (
 )
 )
 
 
 
 
-class ClusterTypeCSVForm(CustomFieldModelCSVForm):
+class ClusterTypeCSVForm(NetBoxModelCSVForm):
     slug = SlugField()
     slug = SlugField()
 
 
     class Meta:
     class Meta:
@@ -23,7 +23,7 @@ class ClusterTypeCSVForm(CustomFieldModelCSVForm):
         fields = ('name', 'slug', 'description')
         fields = ('name', 'slug', 'description')
 
 
 
 
-class ClusterGroupCSVForm(CustomFieldModelCSVForm):
+class ClusterGroupCSVForm(NetBoxModelCSVForm):
     slug = SlugField()
     slug = SlugField()
 
 
     class Meta:
     class Meta:
@@ -31,7 +31,7 @@ class ClusterGroupCSVForm(CustomFieldModelCSVForm):
         fields = ('name', 'slug', 'description')
         fields = ('name', 'slug', 'description')
 
 
 
 
-class ClusterCSVForm(CustomFieldModelCSVForm):
+class ClusterCSVForm(NetBoxModelCSVForm):
     type = CSVModelChoiceField(
     type = CSVModelChoiceField(
         queryset=ClusterType.objects.all(),
         queryset=ClusterType.objects.all(),
         to_field_name='name',
         to_field_name='name',
@@ -61,7 +61,7 @@ class ClusterCSVForm(CustomFieldModelCSVForm):
         fields = ('name', 'type', 'group', 'site', 'comments')
         fields = ('name', 'type', 'group', 'site', 'comments')
 
 
 
 
-class VirtualMachineCSVForm(CustomFieldModelCSVForm):
+class VirtualMachineCSVForm(NetBoxModelCSVForm):
     status = CSVChoiceField(
     status = CSVChoiceField(
         choices=VirtualMachineStatusChoices,
         choices=VirtualMachineStatusChoices,
         help_text='Operational status of device'
         help_text='Operational status of device'
@@ -99,7 +99,7 @@ class VirtualMachineCSVForm(CustomFieldModelCSVForm):
         )
         )
 
 
 
 
-class VMInterfaceCSVForm(CustomFieldModelCSVForm):
+class VMInterfaceCSVForm(NetBoxModelCSVForm):
     virtual_machine = CSVModelChoiceField(
     virtual_machine = CSVModelChoiceField(
         queryset=VirtualMachine.objects.all(),
         queryset=VirtualMachine.objects.all(),
         to_field_name='name'
         to_field_name='name'

+ 25 - 24
netbox/virtualization/forms/filtersets.py

@@ -2,7 +2,8 @@ from django import forms
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
 
 
 from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup
 from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup
-from extras.forms import CustomFieldModelFilterForm, LocalConfigContextFilterForm
+from extras.forms import LocalConfigContextFilterForm
+from netbox.forms import NetBoxModelFilterSetForm
 from tenancy.forms import TenancyFilterForm
 from tenancy.forms import TenancyFilterForm
 from utilities.forms import (
 from utilities.forms import (
     DynamicModelMultipleChoiceField, StaticSelect, StaticSelectMultiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES,
     DynamicModelMultipleChoiceField, StaticSelect, StaticSelectMultiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES,
@@ -19,24 +20,24 @@ __all__ = (
 )
 )
 
 
 
 
-class ClusterTypeFilterForm(CustomFieldModelFilterForm):
+class ClusterTypeFilterForm(NetBoxModelFilterSetForm):
     model = ClusterType
     model = ClusterType
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
-class ClusterGroupFilterForm(CustomFieldModelFilterForm):
+class ClusterGroupFilterForm(NetBoxModelFilterSetForm):
     model = ClusterGroup
     model = ClusterGroup
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
-class ClusterFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
+class ClusterFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
     model = Cluster
     model = Cluster
-    field_groups = [
-        ['q', 'tag'],
-        ['group_id', 'type_id'],
-        ['region_id', 'site_group_id', 'site_id'],
-        ['tenant_group_id', 'tenant_id'],
-    ]
+    fieldsets = (
+        (None, ('q', 'tag')),
+        ('Attributes', ('group_id', 'type_id')),
+        ('Location', ('region_id', 'site_group_id', 'site_id')),
+        ('Tenant', ('tenant_group_id', 'tenant_id')),
+    )
     type_id = DynamicModelMultipleChoiceField(
     type_id = DynamicModelMultipleChoiceField(
         queryset=ClusterType.objects.all(),
         queryset=ClusterType.objects.all(),
         required=False,
         required=False,
@@ -71,15 +72,15 @@ class ClusterFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
-class VirtualMachineFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, CustomFieldModelFilterForm):
+class VirtualMachineFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, NetBoxModelFilterSetForm):
     model = VirtualMachine
     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'],
-    ]
+    fieldsets = (
+        (None, ('q', 'tag')),
+        ('Cluster', ('cluster_group_id', 'cluster_type_id', 'cluster_id')),
+        ('Location', ('region_id', 'site_group_id', 'site_id')),
+        ('Attriubtes', ('status', 'role_id', 'platform_id', 'mac_address', 'has_primary_ip', 'local_context_data')),
+        ('Tenant', ('tenant_group_id', 'tenant_id')),
+    )
     cluster_group_id = DynamicModelMultipleChoiceField(
     cluster_group_id = DynamicModelMultipleChoiceField(
         queryset=ClusterGroup.objects.all(),
         queryset=ClusterGroup.objects.all(),
         required=False,
         required=False,
@@ -151,13 +152,13 @@ class VirtualMachineFilterForm(LocalConfigContextFilterForm, TenancyFilterForm,
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
-class VMInterfaceFilterForm(CustomFieldModelFilterForm):
+class VMInterfaceFilterForm(NetBoxModelFilterSetForm):
     model = VMInterface
     model = VMInterface
-    field_groups = [
-        ['q', 'tag'],
-        ['cluster_id', 'virtual_machine_id'],
-        ['enabled', 'mac_address'],
-    ]
+    fieldsets = (
+        (None, ('q', 'tag')),
+        ('Virtual Machine', ('cluster_id', 'virtual_machine_id')),
+        ('Attributes', ('enabled', 'mac_address')),
+    )
     cluster_id = DynamicModelMultipleChoiceField(
     cluster_id = DynamicModelMultipleChoiceField(
         queryset=Cluster.objects.all(),
         queryset=Cluster.objects.all(),
         required=False,
         required=False,

+ 20 - 18
netbox/virtualization/forms/models.py

@@ -5,9 +5,9 @@ from django.core.exceptions import ValidationError
 from dcim.forms.common import InterfaceCommonForm
 from dcim.forms.common import InterfaceCommonForm
 from dcim.forms.models import INTERFACE_MODE_HELP_TEXT
 from dcim.forms.models import INTERFACE_MODE_HELP_TEXT
 from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site, SiteGroup
 from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site, SiteGroup
-from extras.forms import CustomFieldModelForm
 from extras.models import Tag
 from extras.models import Tag
 from ipam.models import IPAddress, VLAN, VLANGroup
 from ipam.models import IPAddress, VLAN, VLANGroup
+from netbox.forms import NetBoxModelForm
 from tenancy.forms import TenancyForm
 from tenancy.forms import TenancyForm
 from utilities.forms import (
 from utilities.forms import (
     BootstrapMixin, CommentField, ConfirmationForm, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
     BootstrapMixin, CommentField, ConfirmationForm, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
@@ -26,7 +26,7 @@ __all__ = (
 )
 )
 
 
 
 
-class ClusterTypeForm(CustomFieldModelForm):
+class ClusterTypeForm(NetBoxModelForm):
     slug = SlugField()
     slug = SlugField()
     tags = DynamicModelMultipleChoiceField(
     tags = DynamicModelMultipleChoiceField(
         queryset=Tag.objects.all(),
         queryset=Tag.objects.all(),
@@ -40,7 +40,7 @@ class ClusterTypeForm(CustomFieldModelForm):
         )
         )
 
 
 
 
-class ClusterGroupForm(CustomFieldModelForm):
+class ClusterGroupForm(NetBoxModelForm):
     slug = SlugField()
     slug = SlugField()
     tags = DynamicModelMultipleChoiceField(
     tags = DynamicModelMultipleChoiceField(
         queryset=Tag.objects.all(),
         queryset=Tag.objects.all(),
@@ -54,7 +54,7 @@ class ClusterGroupForm(CustomFieldModelForm):
         )
         )
 
 
 
 
-class ClusterForm(TenancyForm, CustomFieldModelForm):
+class ClusterForm(TenancyForm, NetBoxModelForm):
     type = DynamicModelChoiceField(
     type = DynamicModelChoiceField(
         queryset=ClusterType.objects.all()
         queryset=ClusterType.objects.all()
     )
     )
@@ -90,15 +90,16 @@ class ClusterForm(TenancyForm, CustomFieldModelForm):
         required=False
         required=False
     )
     )
 
 
+    fieldsets = (
+        ('Cluster', ('name', 'type', 'group', 'region', 'site_group', 'site', 'tags')),
+        ('Tenancy', ('tenant_group', 'tenant')),
+    )
+
     class Meta:
     class Meta:
         model = Cluster
         model = Cluster
         fields = (
         fields = (
             'name', 'type', 'group', 'tenant', 'region', 'site_group', 'site', 'comments', 'tags',
             '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):
 class ClusterAddDevicesForm(BootstrapMixin, forms.Form):
@@ -171,7 +172,7 @@ class ClusterRemoveDevicesForm(ConfirmationForm):
     )
     )
 
 
 
 
-class VirtualMachineForm(TenancyForm, CustomFieldModelForm):
+class VirtualMachineForm(TenancyForm, NetBoxModelForm):
     cluster_group = DynamicModelChoiceField(
     cluster_group = DynamicModelChoiceField(
         queryset=ClusterGroup.objects.all(),
         queryset=ClusterGroup.objects.all(),
         required=False,
         required=False,
@@ -206,20 +207,21 @@ class VirtualMachineForm(TenancyForm, CustomFieldModelForm):
         required=False
         required=False
     )
     )
 
 
+    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',)),
+    )
+
     class Meta:
     class Meta:
         model = VirtualMachine
         model = VirtualMachine
         fields = [
         fields = [
             'name', 'status', 'cluster_group', 'cluster', 'role', 'tenant_group', 'tenant', 'platform', 'primary_ip4',
             'name', 'status', 'cluster_group', 'cluster', 'role', 'tenant_group', 'tenant', 'platform', 'primary_ip4',
             'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'tags', 'local_context_data',
             '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 = {
         help_texts = {
             'local_context_data': "Local config context data overwrites all sources contexts in the final rendered "
             'local_context_data': "Local config context data overwrites all sources contexts in the final rendered "
                                   "config context",
                                   "config context",
@@ -271,7 +273,7 @@ class VirtualMachineForm(TenancyForm, CustomFieldModelForm):
             self.fields['primary_ip6'].widget.attrs['readonly'] = True
             self.fields['primary_ip6'].widget.attrs['readonly'] = True
 
 
 
 
-class VMInterfaceForm(InterfaceCommonForm, CustomFieldModelForm):
+class VMInterfaceForm(InterfaceCommonForm, NetBoxModelForm):
     parent = DynamicModelChoiceField(
     parent = DynamicModelChoiceField(
         queryset=VMInterface.objects.all(),
         queryset=VMInterface.objects.all(),
         required=False,
         required=False,

+ 11 - 10
netbox/wireless/forms/bulk_edit.py

@@ -1,8 +1,8 @@
 from django import forms
 from django import forms
 
 
 from dcim.choices import LinkStatusChoices
 from dcim.choices import LinkStatusChoices
-from extras.forms import AddRemoveTagsForm, CustomFieldModelBulkEditForm
 from ipam.models import VLAN
 from ipam.models import VLAN
+from netbox.forms import NetBoxModelBulkEditForm
 from utilities.forms import add_blank_choice, DynamicModelChoiceField
 from utilities.forms import add_blank_choice, DynamicModelChoiceField
 from wireless.choices import *
 from wireless.choices import *
 from wireless.constants import SSID_MAX_LENGTH
 from wireless.constants import SSID_MAX_LENGTH
@@ -15,7 +15,7 @@ __all__ = (
 )
 )
 
 
 
 
-class WirelessLANGroupBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class WirelessLANGroupBulkEditForm(NetBoxModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=WirelessLANGroup.objects.all(),
         queryset=WirelessLANGroup.objects.all(),
         widget=forms.MultipleHiddenInput
         widget=forms.MultipleHiddenInput
@@ -29,11 +29,10 @@ class WirelessLANGroupBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditFo
         required=False
         required=False
     )
     )
 
 
-    class Meta:
-        nullable_fields = ['parent', 'description']
+    nullable_fields = ('parent', 'description')
 
 
 
 
-class WirelessLANBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class WirelessLANBulkEditForm(NetBoxModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=WirelessLAN.objects.all(),
         queryset=WirelessLAN.objects.all(),
         widget=forms.MultipleHiddenInput
         widget=forms.MultipleHiddenInput
@@ -68,11 +67,12 @@ class WirelessLANBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
         label='Pre-shared key'
         label='Pre-shared key'
     )
     )
 
 
-    class Meta:
-        nullable_fields = ['ssid', 'group', 'vlan', 'description', 'auth_type', 'auth_cipher', 'auth_psk']
+    nullable_fields = (
+        'ssid', 'group', 'vlan', 'description', 'auth_type', 'auth_cipher', 'auth_psk',
+    )
 
 
 
 
-class WirelessLinkBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class WirelessLinkBulkEditForm(NetBoxModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=WirelessLink.objects.all(),
         queryset=WirelessLink.objects.all(),
         widget=forms.MultipleHiddenInput
         widget=forms.MultipleHiddenInput
@@ -102,5 +102,6 @@ class WirelessLinkBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
         label='Pre-shared key'
         label='Pre-shared key'
     )
     )
 
 
-    class Meta:
-        nullable_fields = ['ssid', 'description', 'auth_type', 'auth_cipher', 'auth_psk']
+    nullable_fields = (
+        'ssid', 'description', 'auth_type', 'auth_cipher', 'auth_psk',
+    )

+ 4 - 4
netbox/wireless/forms/bulk_import.py

@@ -1,7 +1,7 @@
 from dcim.choices import LinkStatusChoices
 from dcim.choices import LinkStatusChoices
 from dcim.models import Interface
 from dcim.models import Interface
-from extras.forms import CustomFieldModelCSVForm
 from ipam.models import VLAN
 from ipam.models import VLAN
+from netbox.forms import NetBoxModelCSVForm
 from utilities.forms import CSVChoiceField, CSVModelChoiceField, SlugField
 from utilities.forms import CSVChoiceField, CSVModelChoiceField, SlugField
 from wireless.choices import *
 from wireless.choices import *
 from wireless.models import *
 from wireless.models import *
@@ -13,7 +13,7 @@ __all__ = (
 )
 )
 
 
 
 
-class WirelessLANGroupCSVForm(CustomFieldModelCSVForm):
+class WirelessLANGroupCSVForm(NetBoxModelCSVForm):
     parent = CSVModelChoiceField(
     parent = CSVModelChoiceField(
         queryset=WirelessLANGroup.objects.all(),
         queryset=WirelessLANGroup.objects.all(),
         required=False,
         required=False,
@@ -27,7 +27,7 @@ class WirelessLANGroupCSVForm(CustomFieldModelCSVForm):
         fields = ('name', 'slug', 'parent', 'description')
         fields = ('name', 'slug', 'parent', 'description')
 
 
 
 
-class WirelessLANCSVForm(CustomFieldModelCSVForm):
+class WirelessLANCSVForm(NetBoxModelCSVForm):
     group = CSVModelChoiceField(
     group = CSVModelChoiceField(
         queryset=WirelessLANGroup.objects.all(),
         queryset=WirelessLANGroup.objects.all(),
         required=False,
         required=False,
@@ -56,7 +56,7 @@ class WirelessLANCSVForm(CustomFieldModelCSVForm):
         fields = ('ssid', 'group', 'description', 'vlan', 'auth_type', 'auth_cipher', 'auth_psk')
         fields = ('ssid', 'group', 'description', 'vlan', 'auth_type', 'auth_cipher', 'auth_psk')
 
 
 
 
-class WirelessLinkCSVForm(CustomFieldModelCSVForm):
+class WirelessLinkCSVForm(NetBoxModelCSVForm):
     status = CSVChoiceField(
     status = CSVChoiceField(
         choices=LinkStatusChoices,
         choices=LinkStatusChoices,
         help_text='Connection status'
         help_text='Connection status'

+ 9 - 8
netbox/wireless/forms/filtersets.py

@@ -2,7 +2,7 @@ from django import forms
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
 
 
 from dcim.choices import LinkStatusChoices
 from dcim.choices import LinkStatusChoices
-from extras.forms import CustomFieldModelFilterForm
+from netbox.forms import NetBoxModelFilterSetForm
 from utilities.forms import add_blank_choice, DynamicModelMultipleChoiceField, StaticSelect, TagFilterField
 from utilities.forms import add_blank_choice, DynamicModelMultipleChoiceField, StaticSelect, TagFilterField
 from wireless.choices import *
 from wireless.choices import *
 from wireless.models import *
 from wireless.models import *
@@ -14,7 +14,7 @@ __all__ = (
 )
 )
 
 
 
 
-class WirelessLANGroupFilterForm(CustomFieldModelFilterForm):
+class WirelessLANGroupFilterForm(NetBoxModelFilterSetForm):
     model = WirelessLANGroup
     model = WirelessLANGroup
     parent_id = DynamicModelMultipleChoiceField(
     parent_id = DynamicModelMultipleChoiceField(
         queryset=WirelessLANGroup.objects.all(),
         queryset=WirelessLANGroup.objects.all(),
@@ -24,12 +24,13 @@ class WirelessLANGroupFilterForm(CustomFieldModelFilterForm):
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
-class WirelessLANFilterForm(CustomFieldModelFilterForm):
+class WirelessLANFilterForm(NetBoxModelFilterSetForm):
     model = WirelessLAN
     model = WirelessLAN
-    field_groups = [
-        ('q', 'tag'),
-        ('group_id',),
-    ]
+    fieldsets = (
+        (None, ('q', 'tag')),
+        ('Attributes', ('ssid', 'group_id',)),
+        ('Authentication', ('auth_type', 'auth_cipher', 'auth_psk')),
+    )
     ssid = forms.CharField(
     ssid = forms.CharField(
         required=False,
         required=False,
         label='SSID'
         label='SSID'
@@ -56,7 +57,7 @@ class WirelessLANFilterForm(CustomFieldModelFilterForm):
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
-class WirelessLinkFilterForm(CustomFieldModelFilterForm):
+class WirelessLinkFilterForm(NetBoxModelFilterSetForm):
     model = WirelessLink
     model = WirelessLink
     ssid = forms.CharField(
     ssid = forms.CharField(
         required=False,
         required=False,

+ 17 - 15
netbox/wireless/forms/models.py

@@ -1,7 +1,7 @@
 from dcim.models import Device, Interface, Location, Site
 from dcim.models import Device, Interface, Location, Site
-from extras.forms import CustomFieldModelForm
 from extras.models import Tag
 from extras.models import Tag
 from ipam.models import VLAN
 from ipam.models import VLAN
+from netbox.forms import NetBoxModelForm
 from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField, StaticSelect
 from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField, StaticSelect
 from wireless.models import *
 from wireless.models import *
 
 
@@ -12,7 +12,7 @@ __all__ = (
 )
 )
 
 
 
 
-class WirelessLANGroupForm(CustomFieldModelForm):
+class WirelessLANGroupForm(NetBoxModelForm):
     parent = DynamicModelChoiceField(
     parent = DynamicModelChoiceField(
         queryset=WirelessLANGroup.objects.all(),
         queryset=WirelessLANGroup.objects.all(),
         required=False
         required=False
@@ -30,7 +30,7 @@ class WirelessLANGroupForm(CustomFieldModelForm):
         ]
         ]
 
 
 
 
-class WirelessLANForm(CustomFieldModelForm):
+class WirelessLANForm(NetBoxModelForm):
     group = DynamicModelChoiceField(
     group = DynamicModelChoiceField(
         queryset=WirelessLANGroup.objects.all(),
         queryset=WirelessLANGroup.objects.all(),
         required=False
         required=False
@@ -45,23 +45,24 @@ class WirelessLANForm(CustomFieldModelForm):
         required=False
         required=False
     )
     )
 
 
+    fieldsets = (
+        ('Wireless LAN', ('ssid', 'group', 'description', 'tags')),
+        ('VLAN', ('vlan',)),
+        ('Authentication', ('auth_type', 'auth_cipher', 'auth_psk')),
+    )
+
     class Meta:
     class Meta:
         model = WirelessLAN
         model = WirelessLAN
         fields = [
         fields = [
             'ssid', 'group', 'description', 'vlan', 'auth_type', 'auth_cipher', 'auth_psk', 'tags',
             'ssid', 'group', 'description', 'vlan', 'auth_type', 'auth_cipher', 'auth_psk', 'tags',
         ]
         ]
-        fieldsets = (
-            ('Wireless LAN', ('ssid', 'group', 'description', 'tags')),
-            ('VLAN', ('vlan',)),
-            ('Authentication', ('auth_type', 'auth_cipher', 'auth_psk')),
-        )
         widgets = {
         widgets = {
             'auth_type': StaticSelect,
             'auth_type': StaticSelect,
             'auth_cipher': StaticSelect,
             'auth_cipher': StaticSelect,
         }
         }
 
 
 
 
-class WirelessLinkForm(CustomFieldModelForm):
+class WirelessLinkForm(NetBoxModelForm):
     site_a = DynamicModelChoiceField(
     site_a = DynamicModelChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         required=False,
         required=False,
@@ -141,18 +142,19 @@ class WirelessLinkForm(CustomFieldModelForm):
         required=False
         required=False
     )
     )
 
 
+    fieldsets = (
+        ('Side A', ('site_a', 'location_a', 'device_a', 'interface_a')),
+        ('Side B', ('site_b', 'location_b', 'device_b', 'interface_b')),
+        ('Link', ('status', 'ssid', 'description', 'tags')),
+        ('Authentication', ('auth_type', 'auth_cipher', 'auth_psk')),
+    )
+
     class Meta:
     class Meta:
         model = WirelessLink
         model = WirelessLink
         fields = [
         fields = [
             'site_a', 'location_a', 'device_a', 'interface_a', 'site_b', 'location_b', 'device_b', 'interface_b',
             'site_a', 'location_a', 'device_a', 'interface_a', 'site_b', 'location_b', 'device_b', 'interface_b',
             'status', 'ssid', 'description', 'auth_type', 'auth_cipher', 'auth_psk', 'tags',
             'status', 'ssid', 'description', 'auth_type', 'auth_cipher', 'auth_psk', 'tags',
         ]
         ]
-        fieldsets = (
-            ('Side A', ('site_a', 'location_a', 'device_a', 'interface_a')),
-            ('Side B', ('site_b', 'location_b', 'device_b', 'interface_b')),
-            ('Link', ('status', 'ssid', 'description', 'tags')),
-            ('Authentication', ('auth_type', 'auth_cipher', 'auth_psk')),
-        )
         widgets = {
         widgets = {
             'status': StaticSelect,
             'status': StaticSelect,
             'auth_type': StaticSelect,
             'auth_type': StaticSelect,