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

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

Closes #8488: Support form components for plugins
Jeremy Stretch 4 лет назад
Родитель
Сommit
4347f624d8
47 измененных файлов с 1625 добавлено и 1567 удалено
  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'
             - Views: 'plugins/development/views.md'
             - Tables: 'plugins/development/tables.md'
+            - Forms: 'plugins/development/forms.md'
             - Filter Sets: 'plugins/development/filtersets.md'
             - REST API: 'plugins/development/rest-api.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.models import *
-from extras.forms import AddRemoveTagsForm, CustomFieldModelBulkEditForm
+from netbox.forms import NetBoxModelBulkEditForm
 from tenancy.models import Tenant
 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(
         queryset=Provider.objects.all(),
         widget=forms.MultipleHiddenInput
@@ -47,13 +47,12 @@ class ProviderBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
         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(
         queryset=ProviderNetwork.objects.all(),
         widget=forms.MultipleHiddenInput
@@ -75,13 +74,12 @@ class ProviderNetworkBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditFor
         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(
         queryset=CircuitType.objects.all(),
         widget=forms.MultipleHiddenInput
@@ -91,11 +89,10 @@ class CircuitTypeBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
         required=False
     )
 
-    class Meta:
-        nullable_fields = ['description']
+    nullable_fields = ('description',)
 
 
-class CircuitBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class CircuitBulkEditForm(NetBoxModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
         queryset=Circuit.objects.all(),
         widget=forms.MultipleHiddenInput
@@ -131,7 +128,6 @@ class CircuitBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
         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.models import *
-from extras.forms import CustomFieldModelCSVForm
+from netbox.forms import NetBoxModelCSVForm
 from tenancy.models import Tenant
 from utilities.forms import CSVChoiceField, CSVModelChoiceField, SlugField
 
@@ -12,7 +12,7 @@ __all__ = (
 )
 
 
-class ProviderCSVForm(CustomFieldModelCSVForm):
+class ProviderCSVForm(NetBoxModelCSVForm):
     slug = SlugField()
 
     class Meta:
@@ -22,7 +22,7 @@ class ProviderCSVForm(CustomFieldModelCSVForm):
         )
 
 
-class ProviderNetworkCSVForm(CustomFieldModelCSVForm):
+class ProviderNetworkCSVForm(NetBoxModelCSVForm):
     provider = CSVModelChoiceField(
         queryset=Provider.objects.all(),
         to_field_name='name',
@@ -36,7 +36,7 @@ class ProviderNetworkCSVForm(CustomFieldModelCSVForm):
         ]
 
 
-class CircuitTypeCSVForm(CustomFieldModelCSVForm):
+class CircuitTypeCSVForm(NetBoxModelCSVForm):
     slug = SlugField()
 
     class Meta:
@@ -47,7 +47,7 @@ class CircuitTypeCSVForm(CustomFieldModelCSVForm):
         }
 
 
-class CircuitCSVForm(CustomFieldModelCSVForm):
+class CircuitCSVForm(NetBoxModelCSVForm):
     provider = CSVModelChoiceField(
         queryset=Provider.objects.all(),
         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.models import *
 from dcim.models import Region, Site, SiteGroup
-from extras.forms import CustomFieldModelFilterForm
+from netbox.forms import NetBoxModelFilterSetForm
 from tenancy.forms import TenancyFilterForm
 from utilities.forms import DynamicModelMultipleChoiceField, StaticSelectMultiple, TagFilterField
 
@@ -16,13 +16,13 @@ __all__ = (
 )
 
 
-class ProviderFilterForm(CustomFieldModelFilterForm):
+class ProviderFilterForm(NetBoxModelFilterSetForm):
     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(
         queryset=Region.objects.all(),
         required=False,
@@ -49,11 +49,11 @@ class ProviderFilterForm(CustomFieldModelFilterForm):
     tag = TagFilterField(model)
 
 
-class ProviderNetworkFilterForm(CustomFieldModelFilterForm):
+class ProviderNetworkFilterForm(NetBoxModelFilterSetForm):
     model = ProviderNetwork
-    field_groups = (
-        ('q', 'tag'),
-        ('provider_id',),
+    fieldsets = (
+        (None, ('q', 'tag')),
+        ('Attributes', ('provider_id', 'service_id')),
     )
     provider_id = DynamicModelMultipleChoiceField(
         queryset=Provider.objects.all(),
@@ -67,20 +67,20 @@ class ProviderNetworkFilterForm(CustomFieldModelFilterForm):
     tag = TagFilterField(model)
 
 
-class CircuitTypeFilterForm(CustomFieldModelFilterForm):
+class CircuitTypeFilterForm(NetBoxModelFilterSetForm):
     model = CircuitType
     tag = TagFilterField(model)
 
 
-class CircuitFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
+class CircuitFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
     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(
         queryset=CircuitType.objects.all(),
         required=False,

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

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

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

@@ -6,8 +6,8 @@ from timezone_field import TimeZoneFormField
 from dcim.choices import *
 from dcim.constants import *
 from dcim.models import *
-from extras.forms import AddRemoveTagsForm, CustomFieldModelBulkEditForm
 from ipam.models import ASN, VLAN, VRF
+from netbox.forms import NetBoxModelBulkEditForm
 from tenancy.models import Tenant
 from utilities.forms import (
     add_blank_choice, BulkEditForm, BulkEditNullBooleanSelect, ColorField, CommentField, DynamicModelChoiceField,
@@ -57,7 +57,7 @@ __all__ = (
 )
 
 
-class RegionBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class RegionBulkEditForm(NetBoxModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
         queryset=Region.objects.all(),
         widget=forms.MultipleHiddenInput
@@ -71,11 +71,10 @@ class RegionBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
         required=False
     )
 
-    class Meta:
-        nullable_fields = ['parent', 'description']
+    nullable_fields = ('parent', 'description')
 
 
-class SiteGroupBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class SiteGroupBulkEditForm(NetBoxModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
         queryset=SiteGroup.objects.all(),
         widget=forms.MultipleHiddenInput
@@ -89,11 +88,10 @@ class SiteGroupBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
         required=False
     )
 
-    class Meta:
-        nullable_fields = ['parent', 'description']
+    nullable_fields = ('parent', 'description')
 
 
-class SiteBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class SiteBulkEditForm(NetBoxModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
         queryset=Site.objects.all(),
         widget=forms.MultipleHiddenInput
@@ -131,13 +129,12 @@ class SiteBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
         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(
         queryset=Location.objects.all(),
         widget=forms.MultipleHiddenInput
@@ -162,11 +159,10 @@ class LocationBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
         required=False
     )
 
-    class Meta:
-        nullable_fields = ['parent', 'tenant', 'description']
+    nullable_fields = ('parent', 'tenant', 'description')
 
 
-class RackRoleBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class RackRoleBulkEditForm(NetBoxModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
         queryset=RackRole.objects.all(),
         widget=forms.MultipleHiddenInput
@@ -179,11 +175,10 @@ class RackRoleBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
         required=False
     )
 
-    class Meta:
-        nullable_fields = ['color', 'description']
+    nullable_fields = ('color', 'description')
 
 
-class RackBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class RackBulkEditForm(NetBoxModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
         queryset=Rack.objects.all(),
         widget=forms.MultipleHiddenInput
@@ -277,13 +272,12 @@ class RackBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
         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(
         queryset=RackReservation.objects.all(),
         widget=forms.MultipleHiddenInput()
@@ -304,11 +298,8 @@ class RackReservationBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditFor
         required=False
     )
 
-    class Meta:
-        nullable_fields = []
-
 
-class ManufacturerBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class ManufacturerBulkEditForm(NetBoxModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
         queryset=Manufacturer.objects.all(),
         widget=forms.MultipleHiddenInput
@@ -318,11 +309,10 @@ class ManufacturerBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
         required=False
     )
 
-    class Meta:
-        nullable_fields = ['description']
+    nullable_fields = ('description',)
 
 
-class DeviceTypeBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class DeviceTypeBulkEditForm(NetBoxModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
         queryset=DeviceType.objects.all(),
         widget=forms.MultipleHiddenInput()
@@ -349,11 +339,10 @@ class DeviceTypeBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
         widget=StaticSelect()
     )
 
-    class Meta:
-        nullable_fields = ['part_number', 'airflow']
+    nullable_fields = ('part_number', 'airflow')
 
 
-class ModuleTypeBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class ModuleTypeBulkEditForm(NetBoxModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
         queryset=ModuleType.objects.all(),
         widget=forms.MultipleHiddenInput()
@@ -366,11 +355,10 @@ class ModuleTypeBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
         required=False
     )
 
-    class Meta:
-        nullable_fields = ['part_number']
+    nullable_fields = ('part_number',)
 
 
-class DeviceRoleBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class DeviceRoleBulkEditForm(NetBoxModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
         queryset=DeviceRole.objects.all(),
         widget=forms.MultipleHiddenInput
@@ -388,11 +376,10 @@ class DeviceRoleBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
         required=False
     )
 
-    class Meta:
-        nullable_fields = ['color', 'description']
+    nullable_fields = ('color', 'description')
 
 
-class PlatformBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class PlatformBulkEditForm(NetBoxModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
         queryset=Platform.objects.all(),
         widget=forms.MultipleHiddenInput
@@ -411,11 +398,10 @@ class PlatformBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
         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(
         queryset=Device.objects.all(),
         widget=forms.MultipleHiddenInput()
@@ -470,13 +456,12 @@ class DeviceBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
         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(
         queryset=Module.objects.all(),
         widget=forms.MultipleHiddenInput()
@@ -498,11 +483,10 @@ class ModuleBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
         label='Serial Number'
     )
 
-    class Meta:
-        nullable_fields = ['serial']
+    nullable_fields = ('serial',)
 
 
-class CableBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class CableBulkEditForm(NetBoxModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
         queryset=Cable.objects.all(),
         widget=forms.MultipleHiddenInput
@@ -541,10 +525,9 @@ class CableBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
         widget=StaticSelect()
     )
 
-    class Meta:
-        nullable_fields = [
-            'type', 'status', 'tenant', 'label', 'color', 'length',
-        ]
+    nullable_fields = (
+        'type', 'status', 'tenant', 'label', 'color', 'length',
+    )
 
     def clean(self):
         super().clean()
@@ -558,7 +541,7 @@ class CableBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
             })
 
 
-class VirtualChassisBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class VirtualChassisBulkEditForm(NetBoxModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
         queryset=VirtualChassis.objects.all(),
         widget=forms.MultipleHiddenInput()
@@ -568,11 +551,10 @@ class VirtualChassisBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm
         required=False
     )
 
-    class Meta:
-        nullable_fields = ['domain']
+    nullable_fields = ('domain',)
 
 
-class PowerPanelBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class PowerPanelBulkEditForm(NetBoxModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
         queryset=PowerPanel.objects.all(),
         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(
         queryset=PowerFeed.objects.all(),
         widget=forms.MultipleHiddenInput
@@ -666,10 +647,7 @@ class PowerFeedBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
         label='Comments'
     )
 
-    class Meta:
-        nullable_fields = [
-            'location', 'comments',
-        ]
+    nullable_fields = ('location', 'comments')
 
 
 #
@@ -691,8 +669,7 @@ class ConsolePortTemplateBulkEditForm(BulkEditForm):
         widget=StaticSelect()
     )
 
-    class Meta:
-        nullable_fields = ('label', 'type', 'description')
+    nullable_fields = ('label', 'type', 'description')
 
 
 class ConsoleServerPortTemplateBulkEditForm(BulkEditForm):
@@ -713,8 +690,7 @@ class ConsoleServerPortTemplateBulkEditForm(BulkEditForm):
         required=False
     )
 
-    class Meta:
-        nullable_fields = ('label', 'type', 'description')
+    nullable_fields = ('label', 'type', 'description')
 
 
 class PowerPortTemplateBulkEditForm(BulkEditForm):
@@ -745,8 +721,7 @@ class PowerPortTemplateBulkEditForm(BulkEditForm):
         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):
@@ -782,8 +757,7 @@ class PowerOutletTemplateBulkEditForm(BulkEditForm):
         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):
         super().__init__(*args, **kwargs)
@@ -820,8 +794,7 @@ class InterfaceTemplateBulkEditForm(BulkEditForm):
         required=False
     )
 
-    class Meta:
-        nullable_fields = ('label', 'description')
+    nullable_fields = ('label', 'description')
 
 
 class FrontPortTemplateBulkEditForm(BulkEditForm):
@@ -845,8 +818,7 @@ class FrontPortTemplateBulkEditForm(BulkEditForm):
         required=False
     )
 
-    class Meta:
-        nullable_fields = ('description',)
+    nullable_fields = ('description',)
 
 
 class RearPortTemplateBulkEditForm(BulkEditForm):
@@ -870,8 +842,7 @@ class RearPortTemplateBulkEditForm(BulkEditForm):
         required=False
     )
 
-    class Meta:
-        nullable_fields = ('description',)
+    nullable_fields = ('description',)
 
 
 class ModuleBayTemplateBulkEditForm(BulkEditForm):
@@ -887,8 +858,7 @@ class ModuleBayTemplateBulkEditForm(BulkEditForm):
         required=False
     )
 
-    class Meta:
-        nullable_fields = ('label', 'position', 'description')
+    nullable_fields = ('label', 'position', 'description')
 
 
 class DeviceBayTemplateBulkEditForm(BulkEditForm):
@@ -904,8 +874,7 @@ class DeviceBayTemplateBulkEditForm(BulkEditForm):
         required=False
     )
 
-    class Meta:
-        nullable_fields = ('label', 'description')
+    nullable_fields = ('label', 'description')
 
 
 class InventoryItemTemplateBulkEditForm(BulkEditForm):
@@ -929,8 +898,7 @@ class InventoryItemTemplateBulkEditForm(BulkEditForm):
         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(
     form_from_model(ConsolePort, ['label', 'type', 'speed', 'mark_connected', 'description']),
-    AddRemoveTagsForm,
-    CustomFieldModelBulkEditForm
+    NetBoxModelBulkEditForm
 ):
     pk = forms.ModelMultipleChoiceField(
         queryset=ConsolePort.objects.all(),
@@ -951,14 +918,12 @@ class ConsolePortBulkEditForm(
         widget=BulkEditNullBooleanSelect
     )
 
-    class Meta:
-        nullable_fields = ['label', 'description']
+    nullable_fields = ('label', 'description')
 
 
 class ConsoleServerPortBulkEditForm(
     form_from_model(ConsoleServerPort, ['label', 'type', 'speed', 'mark_connected', 'description']),
-    AddRemoveTagsForm,
-    CustomFieldModelBulkEditForm
+    NetBoxModelBulkEditForm
 ):
     pk = forms.ModelMultipleChoiceField(
         queryset=ConsoleServerPort.objects.all(),
@@ -969,14 +934,12 @@ class ConsoleServerPortBulkEditForm(
         widget=BulkEditNullBooleanSelect
     )
 
-    class Meta:
-        nullable_fields = ['label', 'description']
+    nullable_fields = ('label', 'description')
 
 
 class PowerPortBulkEditForm(
     form_from_model(PowerPort, ['label', 'type', 'maximum_draw', 'allocated_draw', 'mark_connected', 'description']),
-    AddRemoveTagsForm,
-    CustomFieldModelBulkEditForm
+    NetBoxModelBulkEditForm
 ):
     pk = forms.ModelMultipleChoiceField(
         queryset=PowerPort.objects.all(),
@@ -987,14 +950,12 @@ class PowerPortBulkEditForm(
         widget=BulkEditNullBooleanSelect
     )
 
-    class Meta:
-        nullable_fields = ['label', 'description']
+    nullable_fields = ('label', 'description')
 
 
 class PowerOutletBulkEditForm(
     form_from_model(PowerOutlet, ['label', 'type', 'feed_leg', 'power_port', 'mark_connected', 'description']),
-    AddRemoveTagsForm,
-    CustomFieldModelBulkEditForm
+    NetBoxModelBulkEditForm
 ):
     pk = forms.ModelMultipleChoiceField(
         queryset=PowerOutlet.objects.all(),
@@ -1011,8 +972,7 @@ class PowerOutletBulkEditForm(
         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):
         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',
         'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power',
     ]),
-    AddRemoveTagsForm,
-    CustomFieldModelBulkEditForm
+    NetBoxModelBulkEditForm
 ):
     pk = forms.ModelMultipleChoiceField(
         queryset=Interface.objects.all(),
@@ -1092,11 +1051,10 @@ class InterfaceBulkEditForm(
         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):
         super().__init__(*args, **kwargs)
@@ -1154,64 +1112,55 @@ class InterfaceBulkEditForm(
 
 class FrontPortBulkEditForm(
     form_from_model(FrontPort, ['label', 'type', 'color', 'mark_connected', 'description']),
-    AddRemoveTagsForm,
-    CustomFieldModelBulkEditForm
+    NetBoxModelBulkEditForm
 ):
     pk = forms.ModelMultipleChoiceField(
         queryset=FrontPort.objects.all(),
         widget=forms.MultipleHiddenInput()
     )
 
-    class Meta:
-        nullable_fields = ['label', 'description']
+    nullable_fields = ('label', 'description')
 
 
 class RearPortBulkEditForm(
     form_from_model(RearPort, ['label', 'type', 'color', 'mark_connected', 'description']),
-    AddRemoveTagsForm,
-    CustomFieldModelBulkEditForm
+    NetBoxModelBulkEditForm
 ):
     pk = forms.ModelMultipleChoiceField(
         queryset=RearPort.objects.all(),
         widget=forms.MultipleHiddenInput()
     )
 
-    class Meta:
-        nullable_fields = ['label', 'description']
+    nullable_fields = ('label', 'description')
 
 
 class ModuleBayBulkEditForm(
     form_from_model(DeviceBay, ['label', 'description']),
-    AddRemoveTagsForm,
-    CustomFieldModelBulkEditForm
+    NetBoxModelBulkEditForm
 ):
     pk = forms.ModelMultipleChoiceField(
         queryset=ModuleBay.objects.all(),
         widget=forms.MultipleHiddenInput()
     )
 
-    class Meta:
-        nullable_fields = ['label', 'position', 'description']
+    nullable_fields = ('label', 'position', 'description')
 
 
 class DeviceBayBulkEditForm(
     form_from_model(DeviceBay, ['label', 'description']),
-    AddRemoveTagsForm,
-    CustomFieldModelBulkEditForm
+    NetBoxModelBulkEditForm
 ):
     pk = forms.ModelMultipleChoiceField(
         queryset=DeviceBay.objects.all(),
         widget=forms.MultipleHiddenInput()
     )
 
-    class Meta:
-        nullable_fields = ['label', 'description']
+    nullable_fields = ('label', 'description')
 
 
 class InventoryItemBulkEditForm(
     form_from_model(InventoryItem, ['label', 'role', 'manufacturer', 'part_id', 'description']),
-    AddRemoveTagsForm,
-    CustomFieldModelBulkEditForm
+    NetBoxModelBulkEditForm
 ):
     pk = forms.ModelMultipleChoiceField(
         queryset=InventoryItem.objects.all(),
@@ -1226,15 +1175,14 @@ class InventoryItemBulkEditForm(
         required=False
     )
 
-    class Meta:
-        nullable_fields = ['label', 'role', 'manufacturer', 'part_id', 'description']
+    nullable_fields = ('label', 'role', 'manufacturer', 'part_id', 'description')
 
 
 #
 # Device component roles
 #
 
-class InventoryItemRoleBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class InventoryItemRoleBulkEditForm(NetBoxModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
         queryset=InventoryItemRole.objects.all(),
         widget=forms.MultipleHiddenInput
@@ -1247,5 +1195,4 @@ class InventoryItemRoleBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditF
         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.constants import *
 from dcim.models import *
-from extras.forms import CustomFieldModelCSVForm
 from ipam.models import VRF
+from netbox.forms import NetBoxModelCSVForm
 from tenancy.models import Tenant
 from utilities.forms import CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVTypedChoiceField, SlugField
 from virtualization.models import Cluster
@@ -46,7 +46,7 @@ __all__ = (
 )
 
 
-class RegionCSVForm(CustomFieldModelCSVForm):
+class RegionCSVForm(NetBoxModelCSVForm):
     parent = CSVModelChoiceField(
         queryset=Region.objects.all(),
         required=False,
@@ -59,7 +59,7 @@ class RegionCSVForm(CustomFieldModelCSVForm):
         fields = ('name', 'slug', 'parent', 'description')
 
 
-class SiteGroupCSVForm(CustomFieldModelCSVForm):
+class SiteGroupCSVForm(NetBoxModelCSVForm):
     parent = CSVModelChoiceField(
         queryset=SiteGroup.objects.all(),
         required=False,
@@ -72,7 +72,7 @@ class SiteGroupCSVForm(CustomFieldModelCSVForm):
         fields = ('name', 'slug', 'parent', 'description')
 
 
-class SiteCSVForm(CustomFieldModelCSVForm):
+class SiteCSVForm(NetBoxModelCSVForm):
     status = CSVChoiceField(
         choices=SiteStatusChoices,
         help_text='Operational status'
@@ -109,7 +109,7 @@ class SiteCSVForm(CustomFieldModelCSVForm):
         }
 
 
-class LocationCSVForm(CustomFieldModelCSVForm):
+class LocationCSVForm(NetBoxModelCSVForm):
     site = CSVModelChoiceField(
         queryset=Site.objects.all(),
         to_field_name='name',
@@ -136,7 +136,7 @@ class LocationCSVForm(CustomFieldModelCSVForm):
         fields = ('site', 'parent', 'name', 'slug', 'tenant', 'description')
 
 
-class RackRoleCSVForm(CustomFieldModelCSVForm):
+class RackRoleCSVForm(NetBoxModelCSVForm):
     slug = SlugField()
 
     class Meta:
@@ -147,7 +147,7 @@ class RackRoleCSVForm(CustomFieldModelCSVForm):
         }
 
 
-class RackCSVForm(CustomFieldModelCSVForm):
+class RackCSVForm(NetBoxModelCSVForm):
     site = CSVModelChoiceField(
         queryset=Site.objects.all(),
         to_field_name='name'
@@ -205,7 +205,7 @@ class RackCSVForm(CustomFieldModelCSVForm):
             self.fields['location'].queryset = self.fields['location'].queryset.filter(**params)
 
 
-class RackReservationCSVForm(CustomFieldModelCSVForm):
+class RackReservationCSVForm(NetBoxModelCSVForm):
     site = CSVModelChoiceField(
         queryset=Site.objects.all(),
         to_field_name='name',
@@ -255,14 +255,14 @@ class RackReservationCSVForm(CustomFieldModelCSVForm):
             self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params)
 
 
-class ManufacturerCSVForm(CustomFieldModelCSVForm):
+class ManufacturerCSVForm(NetBoxModelCSVForm):
 
     class Meta:
         model = Manufacturer
         fields = ('name', 'slug', 'description')
 
 
-class DeviceRoleCSVForm(CustomFieldModelCSVForm):
+class DeviceRoleCSVForm(NetBoxModelCSVForm):
     slug = SlugField()
 
     class Meta:
@@ -273,7 +273,7 @@ class DeviceRoleCSVForm(CustomFieldModelCSVForm):
         }
 
 
-class PlatformCSVForm(CustomFieldModelCSVForm):
+class PlatformCSVForm(NetBoxModelCSVForm):
     slug = SlugField()
     manufacturer = CSVModelChoiceField(
         queryset=Manufacturer.objects.all(),
@@ -287,7 +287,7 @@ class PlatformCSVForm(CustomFieldModelCSVForm):
         fields = ('name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description')
 
 
-class BaseDeviceCSVForm(CustomFieldModelCSVForm):
+class BaseDeviceCSVForm(NetBoxModelCSVForm):
     device_role = CSVModelChoiceField(
         queryset=DeviceRole.objects.all(),
         to_field_name='name',
@@ -403,7 +403,7 @@ class DeviceCSVForm(BaseDeviceCSVForm):
             self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params)
 
 
-class ModuleCSVForm(CustomFieldModelCSVForm):
+class ModuleCSVForm(NetBoxModelCSVForm):
     device = CSVModelChoiceField(
         queryset=Device.objects.all(),
         to_field_name='name'
@@ -478,7 +478,7 @@ class ChildDeviceCSVForm(BaseDeviceCSVForm):
 # Device components
 #
 
-class ConsolePortCSVForm(CustomFieldModelCSVForm):
+class ConsolePortCSVForm(NetBoxModelCSVForm):
     device = CSVModelChoiceField(
         queryset=Device.objects.all(),
         to_field_name='name'
@@ -501,7 +501,7 @@ class ConsolePortCSVForm(CustomFieldModelCSVForm):
         fields = ('device', 'name', 'label', 'type', 'speed', 'mark_connected', 'description')
 
 
-class ConsoleServerPortCSVForm(CustomFieldModelCSVForm):
+class ConsoleServerPortCSVForm(NetBoxModelCSVForm):
     device = CSVModelChoiceField(
         queryset=Device.objects.all(),
         to_field_name='name'
@@ -524,7 +524,7 @@ class ConsoleServerPortCSVForm(CustomFieldModelCSVForm):
         fields = ('device', 'name', 'label', 'type', 'speed', 'mark_connected', 'description')
 
 
-class PowerPortCSVForm(CustomFieldModelCSVForm):
+class PowerPortCSVForm(NetBoxModelCSVForm):
     device = CSVModelChoiceField(
         queryset=Device.objects.all(),
         to_field_name='name'
@@ -542,7 +542,7 @@ class PowerPortCSVForm(CustomFieldModelCSVForm):
         )
 
 
-class PowerOutletCSVForm(CustomFieldModelCSVForm):
+class PowerOutletCSVForm(NetBoxModelCSVForm):
     device = CSVModelChoiceField(
         queryset=Device.objects.all(),
         to_field_name='name'
@@ -591,7 +591,7 @@ class PowerOutletCSVForm(CustomFieldModelCSVForm):
             self.fields['power_port'].queryset = PowerPort.objects.none()
 
 
-class InterfaceCSVForm(CustomFieldModelCSVForm):
+class InterfaceCSVForm(NetBoxModelCSVForm):
     device = CSVModelChoiceField(
         queryset=Device.objects.all(),
         to_field_name='name'
@@ -655,7 +655,7 @@ class InterfaceCSVForm(CustomFieldModelCSVForm):
             return self.cleaned_data['enabled']
 
 
-class FrontPortCSVForm(CustomFieldModelCSVForm):
+class FrontPortCSVForm(NetBoxModelCSVForm):
     device = CSVModelChoiceField(
         queryset=Device.objects.all(),
         to_field_name='name'
@@ -703,7 +703,7 @@ class FrontPortCSVForm(CustomFieldModelCSVForm):
             self.fields['rear_port'].queryset = RearPort.objects.none()
 
 
-class RearPortCSVForm(CustomFieldModelCSVForm):
+class RearPortCSVForm(NetBoxModelCSVForm):
     device = CSVModelChoiceField(
         queryset=Device.objects.all(),
         to_field_name='name'
@@ -721,7 +721,7 @@ class RearPortCSVForm(CustomFieldModelCSVForm):
         }
 
 
-class ModuleBayCSVForm(CustomFieldModelCSVForm):
+class ModuleBayCSVForm(NetBoxModelCSVForm):
     device = CSVModelChoiceField(
         queryset=Device.objects.all(),
         to_field_name='name'
@@ -732,7 +732,7 @@ class ModuleBayCSVForm(CustomFieldModelCSVForm):
         fields = ('device', 'name', 'label', 'position', 'description')
 
 
-class DeviceBayCSVForm(CustomFieldModelCSVForm):
+class DeviceBayCSVForm(NetBoxModelCSVForm):
     device = CSVModelChoiceField(
         queryset=Device.objects.all(),
         to_field_name='name'
@@ -778,7 +778,7 @@ class DeviceBayCSVForm(CustomFieldModelCSVForm):
             self.fields['installed_device'].queryset = Interface.objects.none()
 
 
-class InventoryItemCSVForm(CustomFieldModelCSVForm):
+class InventoryItemCSVForm(NetBoxModelCSVForm):
     device = CSVModelChoiceField(
         queryset=Device.objects.all(),
         to_field_name='name'
@@ -827,7 +827,7 @@ class InventoryItemCSVForm(CustomFieldModelCSVForm):
 # Device component roles
 #
 
-class InventoryItemRoleCSVForm(CustomFieldModelCSVForm):
+class InventoryItemRoleCSVForm(NetBoxModelCSVForm):
     slug = SlugField()
 
     class Meta:
@@ -842,7 +842,7 @@ class InventoryItemRoleCSVForm(CustomFieldModelCSVForm):
 # Cables
 #
 
-class CableCSVForm(CustomFieldModelCSVForm):
+class CableCSVForm(NetBoxModelCSVForm):
     # Termination A
     side_a_device = CSVModelChoiceField(
         queryset=Device.objects.all(),
@@ -947,7 +947,7 @@ class CableCSVForm(CustomFieldModelCSVForm):
 # Virtual chassis
 #
 
-class VirtualChassisCSVForm(CustomFieldModelCSVForm):
+class VirtualChassisCSVForm(NetBoxModelCSVForm):
     master = CSVModelChoiceField(
         queryset=Device.objects.all(),
         to_field_name='name',
@@ -964,7 +964,7 @@ class VirtualChassisCSVForm(CustomFieldModelCSVForm):
 # Power
 #
 
-class PowerPanelCSVForm(CustomFieldModelCSVForm):
+class PowerPanelCSVForm(NetBoxModelCSVForm):
     site = CSVModelChoiceField(
         queryset=Site.objects.all(),
         to_field_name='name',
@@ -990,7 +990,7 @@ class PowerPanelCSVForm(CustomFieldModelCSVForm):
             self.fields['location'].queryset = self.fields['location'].queryset.filter(**params)
 
 
-class PowerFeedCSVForm(CustomFieldModelCSVForm):
+class PowerFeedCSVForm(NetBoxModelCSVForm):
     site = CSVModelChoiceField(
         queryset=Site.objects.all(),
         to_field_name='name',

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

@@ -1,7 +1,7 @@
 from circuits.models import Circuit, CircuitTermination, Provider
 from dcim.models import *
-from extras.forms import CustomFieldModelForm
 from extras.models import Tag
+from netbox.forms import NetBoxModelForm
 from tenancy.forms import TenancyForm
 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
     """
@@ -171,7 +171,7 @@ class ConnectCableToRearPortForm(ConnectCableToDeviceForm):
     )
 
 
-class ConnectCableToCircuitTerminationForm(TenancyForm, CustomFieldModelForm):
+class ConnectCableToCircuitTerminationForm(TenancyForm, NetBoxModelForm):
     termination_b_provider = DynamicModelChoiceField(
         queryset=Provider.objects.all(),
         label='Provider',
@@ -229,7 +229,7 @@ class ConnectCableToCircuitTerminationForm(TenancyForm, CustomFieldModelForm):
         return getattr(self.cleaned_data['termination_b_id'], 'pk', None)
 
 
-class ConnectCableToPowerFeedForm(TenancyForm, CustomFieldModelForm):
+class ConnectCableToPowerFeedForm(TenancyForm, NetBoxModelForm):
     termination_b_region = DynamicModelChoiceField(
         queryset=Region.objects.all(),
         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.constants import *
 from dcim.models import *
-from extras.forms import CustomFieldModelFilterForm, LocalConfigContextFilterForm
+from extras.forms import LocalConfigContextFilterForm
 from ipam.models import ASN, VRF
+from netbox.forms import NetBoxModelFilterSetForm
 from tenancy.forms import TenancyFilterForm
 from utilities.forms import (
     APISelectMultiple, add_blank_choice, ColorField, DynamicModelMultipleChoiceField, FilterForm, StaticSelect,
@@ -52,7 +53,7 @@ __all__ = (
 )
 
 
-class DeviceComponentFilterForm(CustomFieldModelFilterForm):
+class DeviceComponentFilterForm(NetBoxModelFilterSetForm):
     name = forms.CharField(
         required=False
     )
@@ -103,7 +104,7 @@ class DeviceComponentFilterForm(CustomFieldModelFilterForm):
     )
 
 
-class RegionFilterForm(CustomFieldModelFilterForm):
+class RegionFilterForm(NetBoxModelFilterSetForm):
     model = Region
     parent_id = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
@@ -113,7 +114,7 @@ class RegionFilterForm(CustomFieldModelFilterForm):
     tag = TagFilterField(model)
 
 
-class SiteGroupFilterForm(CustomFieldModelFilterForm):
+class SiteGroupFilterForm(NetBoxModelFilterSetForm):
     model = SiteGroup
     parent_id = DynamicModelMultipleChoiceField(
         queryset=SiteGroup.objects.all(),
@@ -123,14 +124,13 @@ class SiteGroupFilterForm(CustomFieldModelFilterForm):
     tag = TagFilterField(model)
 
 
-class SiteFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
+class SiteFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
     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(
         choices=SiteStatusChoices,
         required=False,
@@ -154,13 +154,13 @@ class SiteFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
     tag = TagFilterField(model)
 
 
-class LocationFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
+class LocationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
     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(
         queryset=Region.objects.all(),
         required=False,
@@ -192,20 +192,20 @@ class LocationFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
     tag = TagFilterField(model)
 
 
-class RackRoleFilterForm(CustomFieldModelFilterForm):
+class RackRoleFilterForm(NetBoxModelFilterSetForm):
     model = RackRole
     tag = TagFilterField(model)
 
 
-class RackFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
+class RackFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
     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(
         queryset=Region.objects.all(),
         required=False,
@@ -270,14 +270,14 @@ class RackElevationFilterForm(RackFilterForm):
     )
 
 
-class RackReservationFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
+class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
     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(
         queryset=Region.objects.all(),
         required=False,
@@ -308,18 +308,21 @@ class RackReservationFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
     tag = TagFilterField(model)
 
 
-class ManufacturerFilterForm(CustomFieldModelFilterForm):
+class ManufacturerFilterForm(NetBoxModelFilterSetForm):
     model = Manufacturer
     tag = TagFilterField(model)
 
 
-class DeviceTypeFilterForm(CustomFieldModelFilterForm):
+class DeviceTypeFilterForm(NetBoxModelFilterSetForm):
     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(
         queryset=Manufacturer.objects.all(),
         required=False,
@@ -383,13 +386,16 @@ class DeviceTypeFilterForm(CustomFieldModelFilterForm):
     tag = TagFilterField(model)
 
 
-class ModuleTypeFilterForm(CustomFieldModelFilterForm):
+class ModuleTypeFilterForm(NetBoxModelFilterSetForm):
     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(
         queryset=Manufacturer.objects.all(),
         required=False,
@@ -444,12 +450,12 @@ class ModuleTypeFilterForm(CustomFieldModelFilterForm):
     tag = TagFilterField(model)
 
 
-class DeviceRoleFilterForm(CustomFieldModelFilterForm):
+class DeviceRoleFilterForm(NetBoxModelFilterSetForm):
     model = DeviceRole
     tag = TagFilterField(model)
 
 
-class PlatformFilterForm(CustomFieldModelFilterForm):
+class PlatformFilterForm(NetBoxModelFilterSetForm):
     model = Platform
     manufacturer_id = DynamicModelMultipleChoiceField(
         queryset=Manufacturer.objects.all(),
@@ -459,19 +465,19 @@ class PlatformFilterForm(CustomFieldModelFilterForm):
     tag = TagFilterField(model)
 
 
-class DeviceFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, CustomFieldModelFilterForm):
+class DeviceFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, NetBoxModelFilterSetForm):
     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(
         queryset=Region.objects.all(),
         required=False,
@@ -613,13 +619,12 @@ class DeviceFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, CustomFi
     tag = TagFilterField(model)
 
 
-class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, CustomFieldModelFilterForm):
+class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, NetBoxModelFilterSetForm):
     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(
         queryset=Manufacturer.objects.all(),
         required=False,
@@ -644,13 +649,13 @@ class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, CustomFi
     tag = TagFilterField(model)
 
 
-class VirtualChassisFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
+class VirtualChassisFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
     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(
         queryset=Region.objects.all(),
         required=False,
@@ -673,14 +678,14 @@ class VirtualChassisFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
     tag = TagFilterField(model)
 
 
-class CableFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
+class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
     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(
         queryset=Region.objects.all(),
         required=False,
@@ -736,11 +741,11 @@ class CableFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
     tag = TagFilterField(model)
 
 
-class PowerPanelFilterForm(CustomFieldModelFilterForm):
+class PowerPanelFilterForm(NetBoxModelFilterSetForm):
     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(
         queryset=Region.objects.all(),
@@ -773,14 +778,13 @@ class PowerPanelFilterForm(CustomFieldModelFilterForm):
     tag = TagFilterField(model)
 
 
-class PowerFeedFilterForm(CustomFieldModelFilterForm):
+class PowerFeedFilterForm(NetBoxModelFilterSetForm):
     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(
         queryset=Region.objects.all(),
         required=False,
@@ -855,11 +859,11 @@ class PowerFeedFilterForm(CustomFieldModelFilterForm):
 
 class ConsolePortFilterForm(DeviceComponentFilterForm):
     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(
         choices=ConsolePortTypeChoices,
         required=False,
@@ -875,11 +879,11 @@ class ConsolePortFilterForm(DeviceComponentFilterForm):
 
 class ConsoleServerPortFilterForm(DeviceComponentFilterForm):
     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(
         choices=ConsolePortTypeChoices,
         required=False,
@@ -895,11 +899,11 @@ class ConsoleServerPortFilterForm(DeviceComponentFilterForm):
 
 class PowerPortFilterForm(DeviceComponentFilterForm):
     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(
         choices=PowerPortTypeChoices,
         required=False,
@@ -910,11 +914,11 @@ class PowerPortFilterForm(DeviceComponentFilterForm):
 
 class PowerOutletFilterForm(DeviceComponentFilterForm):
     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(
         choices=PowerOutletTypeChoices,
         required=False,
@@ -925,13 +929,13 @@ class PowerOutletFilterForm(DeviceComponentFilterForm):
 
 class InterfaceFilterForm(DeviceComponentFilterForm):
     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(
         choices=InterfaceKindChoices,
         required=False,
@@ -1008,11 +1012,11 @@ class InterfaceFilterForm(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
     type = forms.MultipleChoiceField(
         choices=PortTypeChoices,
@@ -1027,11 +1031,11 @@ class FrontPortFilterForm(DeviceComponentFilterForm):
 
 class RearPortFilterForm(DeviceComponentFilterForm):
     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(
         choices=PortTypeChoices,
         required=False,
@@ -1045,11 +1049,11 @@ class RearPortFilterForm(DeviceComponentFilterForm):
 
 class ModuleBayFilterForm(DeviceComponentFilterForm):
     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)
     position = forms.CharField(
         required=False
@@ -1058,21 +1062,21 @@ class ModuleBayFilterForm(DeviceComponentFilterForm):
 
 class DeviceBayFilterForm(DeviceComponentFilterForm):
     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)
 
 
 class InventoryItemFilterForm(DeviceComponentFilterForm):
     model = InventoryItem
-    field_groups = [
-        ['q', 'tag'],
-        ['name', 'label', 'manufacturer_id', 'serial', 'asset_tag', 'discovered'],
-        ['region_id', 'site_group_id', 'site_id', 'location_id', '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(
         queryset=InventoryItemRole.objects.all(),
         required=False,
@@ -1103,7 +1107,7 @@ class InventoryItemFilterForm(DeviceComponentFilterForm):
 # Device component roles
 #
 
-class InventoryItemRoleFilterForm(CustomFieldModelFilterForm):
+class InventoryItemRoleFilterForm(NetBoxModelFilterSetForm):
     model = InventoryItemRole
     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.constants import *
 from dcim.models import *
-from extras.forms import CustomFieldModelForm
 from extras.models import Tag
 from ipam.models import ASN, IPAddress, VLAN, VLANGroup, VRF
+from netbox.forms import NetBoxModelForm
 from tenancy.forms import TenancyForm
 from utilities.forms import (
     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(
         queryset=Region.objects.all(),
         required=False
@@ -90,7 +90,7 @@ class RegionForm(CustomFieldModelForm):
         )
 
 
-class SiteGroupForm(CustomFieldModelForm):
+class SiteGroupForm(NetBoxModelForm):
     parent = DynamicModelChoiceField(
         queryset=SiteGroup.objects.all(),
         required=False
@@ -108,7 +108,7 @@ class SiteGroupForm(CustomFieldModelForm):
         )
 
 
-class SiteForm(TenancyForm, CustomFieldModelForm):
+class SiteForm(TenancyForm, NetBoxModelForm):
     region = DynamicModelChoiceField(
         queryset=Region.objects.all(),
         required=False
@@ -134,19 +134,20 @@ class SiteForm(TenancyForm, CustomFieldModelForm):
         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:
         model = Site
         fields = (
             'name', 'slug', 'status', 'region', 'group', 'tenant_group', 'tenant', 'facility', 'asns', 'time_zone',
             '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 = {
             'physical_address': SmallTextarea(
                 attrs={
@@ -173,7 +174,7 @@ class SiteForm(TenancyForm, CustomFieldModelForm):
         }
 
 
-class LocationForm(TenancyForm, CustomFieldModelForm):
+class LocationForm(TenancyForm, NetBoxModelForm):
     region = DynamicModelChoiceField(
         queryset=Region.objects.all(),
         required=False,
@@ -208,20 +209,21 @@ class LocationForm(TenancyForm, CustomFieldModelForm):
         required=False
     )
 
+    fieldsets = (
+        ('Location', (
+            'region', 'site_group', 'site', 'parent', 'name', 'slug', 'description', 'tags',
+        )),
+        ('Tenancy', ('tenant_group', 'tenant')),
+    )
+
     class Meta:
         model = Location
         fields = (
             '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()
     tags = DynamicModelMultipleChoiceField(
         queryset=Tag.objects.all(),
@@ -235,7 +237,7 @@ class RackRoleForm(CustomFieldModelForm):
         ]
 
 
-class RackForm(TenancyForm, CustomFieldModelForm):
+class RackForm(TenancyForm, NetBoxModelForm):
     region = DynamicModelChoiceField(
         queryset=Region.objects.all(),
         required=False,
@@ -295,7 +297,7 @@ class RackForm(TenancyForm, CustomFieldModelForm):
         }
 
 
-class RackReservationForm(TenancyForm, CustomFieldModelForm):
+class RackReservationForm(TenancyForm, NetBoxModelForm):
     region = DynamicModelChoiceField(
         queryset=Region.objects.all(),
         required=False,
@@ -347,19 +349,20 @@ class RackReservationForm(TenancyForm, CustomFieldModelForm):
         required=False
     )
 
+    fieldsets = (
+        ('Reservation', ('region', 'site', 'location', 'rack', 'units', 'user', 'description', 'tags')),
+        ('Tenancy', ('tenant_group', 'tenant')),
+    )
+
     class Meta:
         model = RackReservation
         fields = [
             'region', 'site_group', 'site', 'location', 'rack', 'units', 'user', 'tenant_group', 'tenant',
             'description', 'tags',
         ]
-        fieldsets = (
-            ('Reservation', ('region', 'site', 'location', 'rack', 'units', 'user', 'description', 'tags')),
-            ('Tenancy', ('tenant_group', 'tenant')),
-        )
 
 
-class ManufacturerForm(CustomFieldModelForm):
+class ManufacturerForm(NetBoxModelForm):
     slug = SlugField()
     tags = DynamicModelMultipleChoiceField(
         queryset=Tag.objects.all(),
@@ -373,7 +376,7 @@ class ManufacturerForm(CustomFieldModelForm):
         ]
 
 
-class DeviceTypeForm(CustomFieldModelForm):
+class DeviceTypeForm(NetBoxModelForm):
     manufacturer = DynamicModelChoiceField(
         queryset=Manufacturer.objects.all()
     )
@@ -386,21 +389,22 @@ class DeviceTypeForm(CustomFieldModelForm):
         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:
         model = DeviceType
         fields = [
             'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow',
             '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 = {
             'subdevice_role': StaticSelect(),
             'front_image': ClearableFileInput(attrs={
@@ -412,7 +416,7 @@ class DeviceTypeForm(CustomFieldModelForm):
         }
 
 
-class ModuleTypeForm(CustomFieldModelForm):
+class ModuleTypeForm(NetBoxModelForm):
     manufacturer = DynamicModelChoiceField(
         queryset=Manufacturer.objects.all()
     )
@@ -429,7 +433,7 @@ class ModuleTypeForm(CustomFieldModelForm):
         ]
 
 
-class DeviceRoleForm(CustomFieldModelForm):
+class DeviceRoleForm(NetBoxModelForm):
     slug = SlugField()
     tags = DynamicModelMultipleChoiceField(
         queryset=Tag.objects.all(),
@@ -443,7 +447,7 @@ class DeviceRoleForm(CustomFieldModelForm):
         ]
 
 
-class PlatformForm(CustomFieldModelForm):
+class PlatformForm(NetBoxModelForm):
     manufacturer = DynamicModelChoiceField(
         queryset=Manufacturer.objects.all(),
         required=False
@@ -466,7 +470,7 @@ class PlatformForm(CustomFieldModelForm):
         }
 
 
-class DeviceForm(TenancyForm, CustomFieldModelForm):
+class DeviceForm(TenancyForm, NetBoxModelForm):
     region = DynamicModelChoiceField(
         queryset=Region.objects.all(),
         required=False,
@@ -648,7 +652,7 @@ class DeviceForm(TenancyForm, CustomFieldModelForm):
             self.fields['position'].widget.choices = [(position, f'U{position}')]
 
 
-class ModuleForm(CustomFieldModelForm):
+class ModuleForm(NetBoxModelForm):
     device = DynamicModelChoiceField(
         queryset=Device.objects.all(),
         required=False,
@@ -688,7 +692,7 @@ class ModuleForm(CustomFieldModelForm):
         ]
 
 
-class CableForm(TenancyForm, CustomFieldModelForm):
+class CableForm(TenancyForm, NetBoxModelForm):
     tags = DynamicModelMultipleChoiceField(
         queryset=Tag.objects.all(),
         required=False
@@ -711,7 +715,7 @@ class CableForm(TenancyForm, CustomFieldModelForm):
         }
 
 
-class PowerPanelForm(CustomFieldModelForm):
+class PowerPanelForm(NetBoxModelForm):
     region = DynamicModelChoiceField(
         queryset=Region.objects.all(),
         required=False,
@@ -745,17 +749,18 @@ class PowerPanelForm(CustomFieldModelForm):
         required=False
     )
 
+    fieldsets = (
+        ('Power Panel', ('region', 'site_group', 'site', 'location', 'name', 'tags')),
+    )
+
     class Meta:
         model = PowerPanel
         fields = [
             '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(
         queryset=Region.objects.all(),
         required=False,
@@ -800,17 +805,18 @@ class PowerFeedForm(CustomFieldModelForm):
         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:
         model = PowerFeed
         fields = [
             'region', 'site_group', 'site', 'power_panel', 'rack', 'name', 'status', 'type', 'mark_connected', 'supply',
             'phase', 'voltage', 'amperage', 'max_utilization', 'comments', 'tags',
         ]
-        fieldsets = (
-            ('Power Panel', ('region', 'site', 'power_panel')),
-            ('Power Feed', ('rack', 'name', 'status', 'type', 'mark_connected', 'tags')),
-            ('Characteristics', ('supply', 'voltage', 'amperage', 'phase', 'max_utilization')),
-        )
         widgets = {
             'status': StaticSelect(),
             'type': StaticSelect(),
@@ -823,7 +829,7 @@ class PowerFeedForm(CustomFieldModelForm):
 # Virtual chassis
 #
 
-class VirtualChassisForm(CustomFieldModelForm):
+class VirtualChassisForm(NetBoxModelForm):
     master = forms.ModelChoiceField(
         queryset=Device.objects.all(),
         required=False,
@@ -1101,16 +1107,17 @@ class InventoryItemTemplateForm(BootstrapMixin, forms.ModelForm):
         widget=forms.HiddenInput
     )
 
+    fieldsets = (
+        ('Inventory Item', ('device_type', 'parent', 'name', 'label', 'role', 'description')),
+        ('Hardware', ('manufacturer', 'part_id')),
+    )
+
     class Meta:
         model = InventoryItemTemplate
         fields = [
             'device_type', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'description',
             'component_type', 'component_id',
         ]
-        fieldsets = (
-            ('Inventory Item', ('device_type', 'parent', 'name', 'label', 'role', 'description')),
-            ('Hardware', ('manufacturer', 'part_id')),
-        )
         widgets = {
             'device_type': forms.HiddenInput(),
         }
@@ -1120,7 +1127,7 @@ class InventoryItemTemplateForm(BootstrapMixin, forms.ModelForm):
 # Device components
 #
 
-class ConsolePortForm(CustomFieldModelForm):
+class ConsolePortForm(NetBoxModelForm):
     tags = DynamicModelMultipleChoiceField(
         queryset=Tag.objects.all(),
         required=False
@@ -1138,7 +1145,7 @@ class ConsolePortForm(CustomFieldModelForm):
         }
 
 
-class ConsoleServerPortForm(CustomFieldModelForm):
+class ConsoleServerPortForm(NetBoxModelForm):
     tags = DynamicModelMultipleChoiceField(
         queryset=Tag.objects.all(),
         required=False
@@ -1156,7 +1163,7 @@ class ConsoleServerPortForm(CustomFieldModelForm):
         }
 
 
-class PowerPortForm(CustomFieldModelForm):
+class PowerPortForm(NetBoxModelForm):
     tags = DynamicModelMultipleChoiceField(
         queryset=Tag.objects.all(),
         required=False
@@ -1174,7 +1181,7 @@ class PowerPortForm(CustomFieldModelForm):
         }
 
 
-class PowerOutletForm(CustomFieldModelForm):
+class PowerOutletForm(NetBoxModelForm):
     power_port = DynamicModelChoiceField(
         queryset=PowerPort.objects.all(),
         required=False,
@@ -1199,7 +1206,7 @@ class PowerOutletForm(CustomFieldModelForm):
         }
 
 
-class InterfaceForm(InterfaceCommonForm, CustomFieldModelForm):
+class InterfaceForm(InterfaceCommonForm, NetBoxModelForm):
     parent = DynamicModelChoiceField(
         queryset=Interface.objects.all(),
         required=False,
@@ -1271,6 +1278,17 @@ class InterfaceForm(InterfaceCommonForm, CustomFieldModelForm):
         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:
         model = Interface
         fields = [
@@ -1278,17 +1296,6 @@ class InterfaceForm(InterfaceCommonForm, CustomFieldModelForm):
             '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',
         ]
-        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 = {
             'device': forms.HiddenInput(),
             'type': StaticSelect(),
@@ -1308,7 +1315,7 @@ class InterfaceForm(InterfaceCommonForm, CustomFieldModelForm):
         }
 
 
-class FrontPortForm(CustomFieldModelForm):
+class FrontPortForm(NetBoxModelForm):
     rear_port = DynamicModelChoiceField(
         queryset=RearPort.objects.all(),
         query_params={
@@ -1332,7 +1339,7 @@ class FrontPortForm(CustomFieldModelForm):
         }
 
 
-class RearPortForm(CustomFieldModelForm):
+class RearPortForm(NetBoxModelForm):
     tags = DynamicModelMultipleChoiceField(
         queryset=Tag.objects.all(),
         required=False
@@ -1349,7 +1356,7 @@ class RearPortForm(CustomFieldModelForm):
         }
 
 
-class ModuleBayForm(CustomFieldModelForm):
+class ModuleBayForm(NetBoxModelForm):
     tags = DynamicModelMultipleChoiceField(
         queryset=Tag.objects.all(),
         required=False
@@ -1365,7 +1372,7 @@ class ModuleBayForm(CustomFieldModelForm):
         }
 
 
-class DeviceBayForm(CustomFieldModelForm):
+class DeviceBayForm(NetBoxModelForm):
     tags = DynamicModelMultipleChoiceField(
         queryset=Tag.objects.all(),
         required=False
@@ -1401,7 +1408,7 @@ class PopulateDeviceBayForm(BootstrapMixin, forms.Form):
         ).exclude(pk=device_bay.device.pk)
 
 
-class InventoryItemForm(CustomFieldModelForm):
+class InventoryItemForm(NetBoxModelForm):
     parent = DynamicModelChoiceField(
         queryset=InventoryItem.objects.all(),
         required=False,
@@ -1432,16 +1439,17 @@ class InventoryItemForm(CustomFieldModelForm):
         required=False
     )
 
+    fieldsets = (
+        ('Inventory Item', ('device', 'parent', 'name', 'label', 'role', 'description', 'tags')),
+        ('Hardware', ('manufacturer', 'part_id', 'serial', 'asset_tag')),
+    )
+
     class Meta:
         model = InventoryItem
         fields = [
             'device', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag',
             'description', 'component_type', 'component_id', 'tags',
         ]
-        fieldsets = (
-            ('Inventory Item', ('device', 'parent', 'name', 'label', 'role', 'description', 'tags')),
-            ('Hardware', ('manufacturer', 'part_id', 'serial', 'asset_tag')),
-        )
         widgets = {
             'device': forms.HiddenInput(),
         }
@@ -1451,7 +1459,7 @@ class InventoryItemForm(CustomFieldModelForm):
 # Device component roles
 #
 
-class InventoryItemRoleForm(CustomFieldModelForm):
+class InventoryItemRoleForm(NetBoxModelForm):
     slug = SlugField()
     tags = DynamicModelMultipleChoiceField(
         queryset=Tag.objects.all(),

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

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

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

@@ -33,8 +33,7 @@ class CustomFieldBulkEditForm(BulkEditForm):
         required=False
     )
 
-    class Meta:
-        nullable_fields = []
+    nullable_fields = ('description',)
 
 
 class CustomLinkBulkEditForm(BulkEditForm):
@@ -64,9 +63,6 @@ class CustomLinkBulkEditForm(BulkEditForm):
         widget=StaticSelect()
     )
 
-    class Meta:
-        nullable_fields = []
-
 
 class ExportTemplateBulkEditForm(BulkEditForm):
     pk = forms.ModelMultipleChoiceField(
@@ -95,8 +91,7 @@ class ExportTemplateBulkEditForm(BulkEditForm):
         widget=BulkEditNullBooleanSelect()
     )
 
-    class Meta:
-        nullable_fields = ['description', 'mime_type', 'file_extension']
+    nullable_fields = ('description', 'mime_type', 'file_extension')
 
 
 class WebhookBulkEditForm(BulkEditForm):
@@ -138,8 +133,7 @@ class WebhookBulkEditForm(BulkEditForm):
         required=False
     )
 
-    class Meta:
-        nullable_fields = ['secret', 'conditions', 'ca_file_path']
+    nullable_fields = ('secret', 'conditions', 'ca_file_path')
 
 
 class TagBulkEditForm(BulkEditForm):
@@ -155,8 +149,7 @@ class TagBulkEditForm(BulkEditForm):
         required=False
     )
 
-    class Meta:
-        nullable_fields = ['description']
+    nullable_fields = ('description',)
 
 
 class ConfigContextBulkEditForm(BulkEditForm):
@@ -177,10 +170,7 @@ class ConfigContextBulkEditForm(BulkEditForm):
         max_length=100
     )
 
-    class Meta:
-        nullable_fields = [
-            'description',
-        ]
+    nullable_fields = ('description',)
 
 
 class JournalEntryBulkEditForm(BulkEditForm):
@@ -196,6 +186,3 @@ class JournalEntryBulkEditForm(BulkEditForm):
         required=False,
         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.db.models import Q
 
-from extras.choices import *
 from extras.models import *
-from utilities.forms import BootstrapMixin, BulkEditBaseForm, CSVModelForm
 
 __all__ = (
-    'CustomFieldModelCSVForm',
-    'CustomFieldModelBulkEditForm',
-    'CustomFieldModelFilterForm',
-    'CustomFieldModelForm',
     'CustomFieldsMixin',
 )
 
@@ -50,76 +42,3 @@ class CustomFieldsMixin:
 
             # Annotate the field in the list of CustomField form fields
             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):
-    field_groups = [
-        ['q'],
-        ['type', 'content_types'],
-        ['weight', 'required'],
-    ]
+    fieldsets = (
+        (None, ('q',)),
+        ('Attributes', ('type', 'content_types', 'weight', 'required')),
+    )
     content_types = ContentTypeMultipleChoiceField(
         queryset=ContentType.objects.all(),
         limit_choices_to=FeatureQuery('custom_fields'),
@@ -56,10 +55,10 @@ class CustomFieldFilterForm(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(
         queryset=ContentType.objects.all(),
         limit_choices_to=FeatureQuery('custom_fields'),
@@ -83,10 +82,10 @@ class CustomLinkFilterForm(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(
         queryset=ContentType.objects.all(),
         limit_choices_to=FeatureQuery('custom_fields'),
@@ -108,11 +107,11 @@ class ExportTemplateFilterForm(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(
         queryset=ContentType.objects.all(),
         limit_choices_to=FeatureQuery('custom_fields'),
@@ -160,13 +159,13 @@ class TagFilterForm(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(
         queryset=Region.objects.all(),
         required=False,
@@ -243,11 +242,11 @@ class LocalConfigContextFilterForm(forms.Form):
 
 class JournalEntryFilterForm(FilterForm):
     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(
         required=False,
         label=_('After'),
@@ -283,11 +282,11 @@ class JournalEntryFilterForm(FilterForm):
 
 class ObjectChangeFilterForm(FilterForm):
     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(
         required=False,
         label=_('After'),

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

@@ -13,7 +13,6 @@ from utilities.forms import (
 from virtualization.models import Cluster, ClusterGroup, ClusterType
 
 __all__ = (
-    'AddRemoveTagsForm',
     'ConfigContextForm',
     'CustomFieldForm',
     'CustomLinkForm',
@@ -31,16 +30,17 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm):
         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:
         model = CustomField
         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 = {
             'type': StaticSelect(),
             'filter_logic': StaticSelect(),
@@ -53,13 +53,14 @@ class CustomLinkForm(BootstrapMixin, forms.ModelForm):
         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:
         model = CustomLink
         fields = '__all__'
-        fieldsets = (
-            ('Custom Link', ('name', 'content_type', 'weight', 'group_name', 'button_class', 'enabled', 'new_window')),
-            ('Templates', ('link_text', 'link_url')),
-        )
         widgets = {
             'button_class': StaticSelect(),
             'link_text': forms.Textarea(attrs={'class': 'font-monospace'}),
@@ -78,14 +79,15 @@ class ExportTemplateForm(BootstrapMixin, forms.ModelForm):
         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:
         model = ExportTemplate
         fields = '__all__'
-        fieldsets = (
-            ('Export Template', ('name', 'content_type', 'description')),
-            ('Template', ('template_code',)),
-            ('Rendering', ('mime_type', 'file_extension', 'as_attachment')),
-        )
         widgets = {
             'template_code': forms.Textarea(attrs={'class': 'font-monospace'}),
         }
@@ -97,18 +99,19 @@ class WebhookForm(BootstrapMixin, forms.ModelForm):
         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:
         model = Webhook
         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 = {
             'type_create': 'Creations',
             'type_update': 'Updates',
@@ -124,30 +127,15 @@ class WebhookForm(BootstrapMixin, forms.ModelForm):
 class TagForm(BootstrapMixin, forms.ModelForm):
     slug = SlugField()
 
+    fieldsets = (
+        ('Tag', ('name', 'slug', 'color', 'description')),
+    )
+
     class Meta:
         model = Tag
         fields = [
             'name', 'slug', 'color', 'description'
         ]
-        fieldsets = (
-            ('Tag', ('name', 'slug', 'color', 'description')),
-        )
-
-
-class AddRemoveTagsForm(forms.Form):
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-
-        # Add add/remove tags fields
-        self.fields['add_tags'] = DynamicModelMultipleChoiceField(
-            queryset=Tag.objects.all(),
-            required=False
-        )
-        self.fields['remove_tags'] = DynamicModelMultipleChoiceField(
-            queryset=Tag.objects.all(),
-            required=False
-        )
 
 
 class ConfigContextForm(BootstrapMixin, forms.ModelForm):

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

@@ -1,11 +1,11 @@
 from django import forms
 
 from dcim.models import Region, Site, SiteGroup
-from extras.forms import AddRemoveTagsForm, CustomFieldModelBulkEditForm
 from ipam.choices import *
 from ipam.constants import *
 from ipam.models import *
 from ipam.models import ASN
+from netbox.forms import NetBoxModelBulkEditForm
 from tenancy.models import Tenant
 from utilities.forms import (
     add_blank_choice, BulkEditNullBooleanSelect, DatePicker, DynamicModelChoiceField, NumericArrayField, StaticSelect,
@@ -30,7 +30,7 @@ __all__ = (
 )
 
 
-class VRFBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class VRFBulkEditForm(NetBoxModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
         queryset=VRF.objects.all(),
         widget=forms.MultipleHiddenInput()
@@ -49,13 +49,10 @@ class VRFBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
         required=False
     )
 
-    class Meta:
-        nullable_fields = [
-            'tenant', 'description',
-        ]
+    nullable_fields = ('tenant', 'description')
 
 
-class RouteTargetBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class RouteTargetBulkEditForm(NetBoxModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
         queryset=RouteTarget.objects.all(),
         widget=forms.MultipleHiddenInput()
@@ -69,13 +66,10 @@ class RouteTargetBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
         required=False
     )
 
-    class Meta:
-        nullable_fields = [
-            'tenant', 'description',
-        ]
+    nullable_fields = ('tenant', 'description')
 
 
-class RIRBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class RIRBulkEditForm(NetBoxModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
         queryset=RIR.objects.all(),
         widget=forms.MultipleHiddenInput
@@ -89,11 +83,10 @@ class RIRBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
         required=False
     )
 
-    class Meta:
-        nullable_fields = ['is_private', 'description']
+    nullable_fields = ('is_private', 'description')
 
 
-class ASNBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class ASNBulkEditForm(NetBoxModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
         queryset=ASN.objects.all(),
         widget=forms.MultipleHiddenInput()
@@ -116,16 +109,10 @@ class ASNBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
         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(
         queryset=Aggregate.objects.all(),
         widget=forms.MultipleHiddenInput()
@@ -147,16 +134,10 @@ class AggregateBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
         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(
         queryset=Role.objects.all(),
         widget=forms.MultipleHiddenInput
@@ -169,11 +150,10 @@ class RoleBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
         required=False
     )
 
-    class Meta:
-        nullable_fields = ['description']
+    nullable_fields = ('description',)
 
 
-class PrefixBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class PrefixBulkEditForm(NetBoxModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
         queryset=Prefix.objects.all(),
         widget=forms.MultipleHiddenInput()
@@ -232,13 +212,12 @@ class PrefixBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
         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(
         queryset=IPRange.objects.all(),
         widget=forms.MultipleHiddenInput()
@@ -266,13 +245,12 @@ class IPRangeBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
         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(
         queryset=IPAddress.objects.all(),
         widget=forms.MultipleHiddenInput()
@@ -311,13 +289,12 @@ class IPAddressBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
         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(
         queryset=FHRPGroup.objects.all(),
         widget=forms.MultipleHiddenInput()
@@ -348,11 +325,10 @@ class FHRPGroupBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
         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(
         queryset=VLANGroup.objects.all(),
         widget=forms.MultipleHiddenInput
@@ -378,11 +354,10 @@ class VLANGroupBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
         required=False
     )
 
-    class Meta:
-        nullable_fields = ['site', 'description']
+    nullable_fields = ('site', 'description')
 
 
-class VLANBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class VLANBulkEditForm(NetBoxModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
         queryset=VLAN.objects.all(),
         widget=forms.MultipleHiddenInput()
@@ -428,13 +403,12 @@ class VLANBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
         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(
         queryset=ServiceTemplate.objects.all(),
         widget=forms.MultipleHiddenInput()
@@ -456,10 +430,7 @@ class ServiceTemplateBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditFor
         required=False
     )
 
-    class Meta:
-        nullable_fields = [
-            'description',
-        ]
+    nullable_fields = ('description',)
 
 
 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 dcim.models import Device, Interface, Site
-from extras.forms import CustomFieldModelCSVForm
 from ipam.choices import *
 from ipam.constants import *
 from ipam.models import *
+from netbox.forms import NetBoxModelCSVForm
 from tenancy.models import Tenant
 from utilities.forms import CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, SlugField
 from virtualization.models import VirtualMachine, VMInterface
@@ -28,7 +28,7 @@ __all__ = (
 )
 
 
-class VRFCSVForm(CustomFieldModelCSVForm):
+class VRFCSVForm(NetBoxModelCSVForm):
     tenant = CSVModelChoiceField(
         queryset=Tenant.objects.all(),
         required=False,
@@ -41,7 +41,7 @@ class VRFCSVForm(CustomFieldModelCSVForm):
         fields = ('name', 'rd', 'tenant', 'enforce_unique', 'description')
 
 
-class RouteTargetCSVForm(CustomFieldModelCSVForm):
+class RouteTargetCSVForm(NetBoxModelCSVForm):
     tenant = CSVModelChoiceField(
         queryset=Tenant.objects.all(),
         required=False,
@@ -54,7 +54,7 @@ class RouteTargetCSVForm(CustomFieldModelCSVForm):
         fields = ('name', 'description', 'tenant')
 
 
-class RIRCSVForm(CustomFieldModelCSVForm):
+class RIRCSVForm(NetBoxModelCSVForm):
     slug = SlugField()
 
     class Meta:
@@ -65,7 +65,7 @@ class RIRCSVForm(CustomFieldModelCSVForm):
         }
 
 
-class AggregateCSVForm(CustomFieldModelCSVForm):
+class AggregateCSVForm(NetBoxModelCSVForm):
     rir = CSVModelChoiceField(
         queryset=RIR.objects.all(),
         to_field_name='name',
@@ -83,7 +83,7 @@ class AggregateCSVForm(CustomFieldModelCSVForm):
         fields = ('prefix', 'rir', 'tenant', 'date_added', 'description')
 
 
-class ASNCSVForm(CustomFieldModelCSVForm):
+class ASNCSVForm(NetBoxModelCSVForm):
     rir = CSVModelChoiceField(
         queryset=RIR.objects.all(),
         to_field_name='name',
@@ -102,7 +102,7 @@ class ASNCSVForm(CustomFieldModelCSVForm):
         help_texts = {}
 
 
-class RoleCSVForm(CustomFieldModelCSVForm):
+class RoleCSVForm(NetBoxModelCSVForm):
     slug = SlugField()
 
     class Meta:
@@ -110,7 +110,7 @@ class RoleCSVForm(CustomFieldModelCSVForm):
         fields = ('name', 'slug', 'weight', 'description')
 
 
-class PrefixCSVForm(CustomFieldModelCSVForm):
+class PrefixCSVForm(NetBoxModelCSVForm):
     vrf = CSVModelChoiceField(
         queryset=VRF.objects.all(),
         to_field_name='name',
@@ -174,7 +174,7 @@ class PrefixCSVForm(CustomFieldModelCSVForm):
                 self.fields['vlan'].queryset = self.fields['vlan'].queryset.filter(**params)
 
 
-class IPRangeCSVForm(CustomFieldModelCSVForm):
+class IPRangeCSVForm(NetBoxModelCSVForm):
     vrf = CSVModelChoiceField(
         queryset=VRF.objects.all(),
         to_field_name='name',
@@ -205,7 +205,7 @@ class IPRangeCSVForm(CustomFieldModelCSVForm):
         )
 
 
-class IPAddressCSVForm(CustomFieldModelCSVForm):
+class IPAddressCSVForm(NetBoxModelCSVForm):
     vrf = CSVModelChoiceField(
         queryset=VRF.objects.all(),
         to_field_name='name',
@@ -312,7 +312,7 @@ class IPAddressCSVForm(CustomFieldModelCSVForm):
         return ipaddress
 
 
-class FHRPGroupCSVForm(CustomFieldModelCSVForm):
+class FHRPGroupCSVForm(NetBoxModelCSVForm):
     protocol = CSVChoiceField(
         choices=FHRPGroupProtocolChoices
     )
@@ -326,7 +326,7 @@ class FHRPGroupCSVForm(CustomFieldModelCSVForm):
         fields = ('protocol', 'group_id', 'auth_type', 'auth_key', 'description')
 
 
-class VLANGroupCSVForm(CustomFieldModelCSVForm):
+class VLANGroupCSVForm(NetBoxModelCSVForm):
     slug = SlugField()
     scope_type = CSVContentTypeField(
         queryset=ContentType.objects.filter(model__in=VLANGROUP_SCOPE_TYPES),
@@ -354,7 +354,7 @@ class VLANGroupCSVForm(CustomFieldModelCSVForm):
         }
 
 
-class VLANCSVForm(CustomFieldModelCSVForm):
+class VLANCSVForm(NetBoxModelCSVForm):
     site = CSVModelChoiceField(
         queryset=Site.objects.all(),
         required=False,
@@ -393,7 +393,7 @@ class VLANCSVForm(CustomFieldModelCSVForm):
         }
 
 
-class ServiceTemplateCSVForm(CustomFieldModelCSVForm):
+class ServiceTemplateCSVForm(NetBoxModelCSVForm):
     protocol = CSVChoiceField(
         choices=ServiceProtocolChoices,
         help_text='IP protocol'
@@ -404,7 +404,7 @@ class ServiceTemplateCSVForm(CustomFieldModelCSVForm):
         fields = ('name', 'protocol', 'ports', 'description')
 
 
-class ServiceCSVForm(CustomFieldModelCSVForm):
+class ServiceCSVForm(NetBoxModelCSVForm):
     device = CSVModelChoiceField(
         queryset=Device.objects.all(),
         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 dcim.models import Location, Rack, Region, Site, SiteGroup
-from extras.forms import CustomFieldModelFilterForm
 from ipam.choices import *
 from ipam.constants import *
 from ipam.models import *
 from ipam.models import ASN
+from netbox.forms import NetBoxModelFilterSetForm
 from tenancy.forms import TenancyFilterForm
 from utilities.forms import (
     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
-    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(
         queryset=RouteTarget.objects.all(),
         required=False,
@@ -59,13 +59,13 @@ class VRFFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
     tag = TagFilterField(model)
 
 
-class RouteTargetFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
+class RouteTargetFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
     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(
         queryset=VRF.objects.all(),
         required=False,
@@ -79,7 +79,7 @@ class RouteTargetFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
     tag = TagFilterField(model)
 
 
-class RIRFilterForm(CustomFieldModelFilterForm):
+class RIRFilterForm(NetBoxModelFilterSetForm):
     model = RIR
     is_private = forms.NullBooleanField(
         required=False,
@@ -91,13 +91,13 @@ class RIRFilterForm(CustomFieldModelFilterForm):
     tag = TagFilterField(model)
 
 
-class AggregateFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
+class AggregateFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
     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(
         required=False,
         choices=add_blank_choice(IPAddressFamilyChoices),
@@ -112,14 +112,13 @@ class AggregateFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
     tag = TagFilterField(model)
 
 
-class ASNFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
+class ASNFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
     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(
         queryset=RIR.objects.all(),
         required=False,
@@ -130,22 +129,23 @@ class ASNFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
         required=False,
         label=_('Site')
     )
+    tag = TagFilterField(model)
 
 
-class RoleFilterForm(CustomFieldModelFilterForm):
+class RoleFilterForm(NetBoxModelFilterSetForm):
     model = Role
     tag = TagFilterField(model)
 
 
-class PrefixFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
+class PrefixFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
     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(
         widget=forms.HiddenInput()
     )
@@ -228,13 +228,13 @@ class PrefixFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
     tag = TagFilterField(model)
 
 
-class IPRangeFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
+class IPRangeFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
     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(
         required=False,
         choices=add_blank_choice(IPAddressFamilyChoices),
@@ -261,14 +261,14 @@ class IPRangeFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
     tag = TagFilterField(model)
 
 
-class IPAddressFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
+class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
     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(
         required=False,
         widget=forms.TextInput(
@@ -321,12 +321,12 @@ class IPAddressFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
     tag = TagFilterField(model)
 
 
-class FHRPGroupFilterForm(CustomFieldModelFilterForm):
+class FHRPGroupFilterForm(NetBoxModelFilterSetForm):
     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(
         choices=FHRPGroupProtocolChoices,
@@ -351,12 +351,12 @@ class FHRPGroupFilterForm(CustomFieldModelFilterForm):
     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
     region = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
@@ -394,14 +394,14 @@ class VLANGroupFilterForm(CustomFieldModelFilterForm):
     tag = TagFilterField(model)
 
 
-class VLANFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
+class VLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
     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(
         queryset=Region.objects.all(),
         required=False,
@@ -448,11 +448,11 @@ class VLANFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
     tag = TagFilterField(model)
 
 
-class ServiceTemplateFilterForm(CustomFieldModelFilterForm):
+class ServiceTemplateFilterForm(NetBoxModelFilterSetForm):
     model = ServiceTemplate
-    field_groups = (
-        ('q', 'tag'),
-        ('protocol', 'port'),
+    fieldsets = (
+        (None, ('q', 'tag')),
+        ('Attributes', ('protocol', 'port')),
     )
     protocol = forms.ChoiceField(
         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 dcim.models import Device, Interface, Location, Rack, Region, Site, SiteGroup
-from extras.forms import CustomFieldModelForm
 from extras.models import Tag
 from ipam.choices import *
 from ipam.constants import *
 from ipam.formfields import IPNetworkFormField
 from ipam.models import *
 from ipam.models import ASN
+from netbox.forms import NetBoxModelForm
 from tenancy.forms import TenancyForm
 from utilities.exceptions import PermissionsViolation
 from utilities.forms import (
@@ -39,7 +39,7 @@ __all__ = (
 )
 
 
-class VRFForm(TenancyForm, CustomFieldModelForm):
+class VRFForm(TenancyForm, NetBoxModelForm):
     import_targets = DynamicModelMultipleChoiceField(
         queryset=RouteTarget.objects.all(),
         required=False
@@ -53,17 +53,18 @@ class VRFForm(TenancyForm, CustomFieldModelForm):
         required=False
     )
 
+    fieldsets = (
+        ('VRF', ('name', 'rd', 'enforce_unique', 'description', 'tags')),
+        ('Route Targets', ('import_targets', 'export_targets')),
+        ('Tenancy', ('tenant_group', 'tenant')),
+    )
+
     class Meta:
         model = VRF
         fields = [
             'name', 'rd', 'enforce_unique', 'description', 'import_targets', 'export_targets', 'tenant_group', 'tenant',
             'tags',
         ]
-        fieldsets = (
-            ('VRF', ('name', 'rd', 'enforce_unique', 'description', 'tags')),
-            ('Route Targets', ('import_targets', 'export_targets')),
-            ('Tenancy', ('tenant_group', 'tenant')),
-        )
         labels = {
             'rd': "RD",
         }
@@ -72,24 +73,25 @@ class VRFForm(TenancyForm, CustomFieldModelForm):
         }
 
 
-class RouteTargetForm(TenancyForm, CustomFieldModelForm):
+class RouteTargetForm(TenancyForm, NetBoxModelForm):
     tags = DynamicModelMultipleChoiceField(
         queryset=Tag.objects.all(),
         required=False
     )
 
+    fieldsets = (
+        ('Route Target', ('name', 'description', 'tags')),
+        ('Tenancy', ('tenant_group', 'tenant')),
+    )
+
     class Meta:
         model = RouteTarget
         fields = [
             'name', 'description', 'tenant_group', 'tenant', 'tags',
         ]
-        fieldsets = (
-            ('Route Target', ('name', 'description', 'tags')),
-            ('Tenancy', ('tenant_group', 'tenant')),
-        )
 
 
-class RIRForm(CustomFieldModelForm):
+class RIRForm(NetBoxModelForm):
     slug = SlugField()
     tags = DynamicModelMultipleChoiceField(
         queryset=Tag.objects.all(),
@@ -103,7 +105,7 @@ class RIRForm(CustomFieldModelForm):
         ]
 
 
-class AggregateForm(TenancyForm, CustomFieldModelForm):
+class AggregateForm(TenancyForm, NetBoxModelForm):
     rir = DynamicModelChoiceField(
         queryset=RIR.objects.all(),
         label='RIR'
@@ -113,15 +115,16 @@ class AggregateForm(TenancyForm, CustomFieldModelForm):
         required=False
     )
 
+    fieldsets = (
+        ('Aggregate', ('prefix', 'rir', 'date_added', 'description', 'tags')),
+        ('Tenancy', ('tenant_group', 'tenant')),
+    )
+
     class Meta:
         model = Aggregate
         fields = [
             'prefix', 'rir', 'date_added', 'description', 'tenant_group', 'tenant', 'tags',
         ]
-        fieldsets = (
-            ('Aggregate', ('prefix', 'rir', 'date_added', 'description', 'tags')),
-            ('Tenancy', ('tenant_group', 'tenant')),
-        )
         help_texts = {
             'prefix': "IPv4 or IPv6 network",
             'rir': "Regional Internet Registry responsible for this prefix",
@@ -131,7 +134,7 @@ class AggregateForm(TenancyForm, CustomFieldModelForm):
         }
 
 
-class ASNForm(TenancyForm, CustomFieldModelForm):
+class ASNForm(TenancyForm, NetBoxModelForm):
     rir = DynamicModelChoiceField(
         queryset=RIR.objects.all(),
         label='RIR',
@@ -146,15 +149,16 @@ class ASNForm(TenancyForm, CustomFieldModelForm):
         required=False
     )
 
+    fieldsets = (
+        ('ASN', ('asn', 'rir', 'sites', 'description', 'tags')),
+        ('Tenancy', ('tenant_group', 'tenant')),
+    )
+
     class Meta:
         model = ASN
         fields = [
             'asn', 'rir', 'sites', 'tenant_group', 'tenant', 'description', 'tags'
         ]
-        fieldsets = (
-            ('ASN', ('asn', 'rir', 'sites', 'description', 'tags')),
-            ('Tenancy', ('tenant_group', 'tenant')),
-        )
         help_texts = {
             'asn': "AS number",
             'rir': "Regional Internet Registry responsible for this prefix",
@@ -175,7 +179,7 @@ class ASNForm(TenancyForm, CustomFieldModelForm):
         return instance
 
 
-class RoleForm(CustomFieldModelForm):
+class RoleForm(NetBoxModelForm):
     slug = SlugField()
     tags = DynamicModelMultipleChoiceField(
         queryset=Tag.objects.all(),
@@ -189,7 +193,7 @@ class RoleForm(CustomFieldModelForm):
         ]
 
 
-class PrefixForm(TenancyForm, CustomFieldModelForm):
+class PrefixForm(TenancyForm, NetBoxModelForm):
     vrf = DynamicModelChoiceField(
         queryset=VRF.objects.all(),
         required=False,
@@ -248,23 +252,24 @@ class PrefixForm(TenancyForm, CustomFieldModelForm):
         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:
         model = Prefix
         fields = [
             'prefix', 'vrf', 'site', 'vlan', 'status', 'role', 'is_pool', 'mark_utilized', 'description',
             'tenant_group', 'tenant', 'tags',
         ]
-        fieldsets = (
-            ('Prefix', ('prefix', 'status', 'vrf', 'role', 'is_pool', 'mark_utilized', 'description', 'tags')),
-            ('Site/VLAN Assignment', ('region', 'site_group', 'site', 'vlan_group', 'vlan')),
-            ('Tenancy', ('tenant_group', 'tenant')),
-        )
         widgets = {
             'status': StaticSelect(),
         }
 
 
-class IPRangeForm(TenancyForm, CustomFieldModelForm):
+class IPRangeForm(TenancyForm, NetBoxModelForm):
     vrf = DynamicModelChoiceField(
         queryset=VRF.objects.all(),
         required=False,
@@ -279,21 +284,22 @@ class IPRangeForm(TenancyForm, CustomFieldModelForm):
         required=False
     )
 
+    fieldsets = (
+        ('IP Range', ('vrf', 'start_address', 'end_address', 'role', 'status', 'description', 'tags')),
+        ('Tenancy', ('tenant_group', 'tenant')),
+    )
+
     class Meta:
         model = IPRange
         fields = [
             'vrf', 'start_address', 'end_address', 'status', 'role', 'description', 'tenant_group', 'tenant', 'tags',
         ]
-        fieldsets = (
-            ('IP Range', ('vrf', 'start_address', 'end_address', 'role', 'status', 'description', 'tags')),
-            ('Tenancy', ('tenant_group', 'tenant')),
-        )
         widgets = {
             'status': StaticSelect(),
         }
 
 
-class IPAddressForm(TenancyForm, CustomFieldModelForm):
+class IPAddressForm(TenancyForm, NetBoxModelForm):
     device = DynamicModelChoiceField(
         queryset=Device.objects.all(),
         required=False,
@@ -506,7 +512,7 @@ class IPAddressForm(TenancyForm, CustomFieldModelForm):
         return ipaddress
 
 
-class IPAddressBulkAddForm(TenancyForm, CustomFieldModelForm):
+class IPAddressBulkAddForm(TenancyForm, NetBoxModelForm):
     vrf = DynamicModelChoiceField(
         queryset=VRF.objects.all(),
         required=False,
@@ -540,7 +546,7 @@ class IPAddressAssignForm(BootstrapMixin, forms.Form):
     )
 
 
-class FHRPGroupForm(CustomFieldModelForm):
+class FHRPGroupForm(NetBoxModelForm):
     tags = DynamicModelMultipleChoiceField(
         queryset=Tag.objects.all(),
         required=False
@@ -562,16 +568,17 @@ class FHRPGroupForm(CustomFieldModelForm):
         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:
         model = FHRPGroup
         fields = (
             '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):
         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)
 
 
-class VLANGroupForm(CustomFieldModelForm):
+class VLANGroupForm(NetBoxModelForm):
     scope_type = ContentTypeChoiceField(
         queryset=ContentType.objects.filter(model__in=VLANGROUP_SCOPE_TYPES),
         required=False
@@ -699,17 +706,18 @@ class VLANGroupForm(CustomFieldModelForm):
         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:
         model = VLANGroup
         fields = [
             'name', 'slug', 'description', 'scope_type', 'region', 'sitegroup', 'site', 'location', 'rack',
             '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 = {
             'scope_type': StaticSelect,
         }
@@ -736,7 +744,7 @@ class VLANGroupForm(CustomFieldModelForm):
             self.instance.scope_id = None
 
 
-class VLANForm(TenancyForm, CustomFieldModelForm):
+class VLANForm(TenancyForm, NetBoxModelForm):
     # VLANGroup assignment fields
     scope_type = forms.ChoiceField(
         choices=(
@@ -817,7 +825,7 @@ class VLANForm(TenancyForm, CustomFieldModelForm):
         }
 
 
-class ServiceTemplateForm(CustomFieldModelForm):
+class ServiceTemplateForm(NetBoxModelForm):
     ports = NumericArrayField(
         base_field=forms.IntegerField(
             min_value=SERVICE_PORT_MIN,
@@ -838,7 +846,7 @@ class ServiceTemplateForm(CustomFieldModelForm):
         }
 
 
-class ServiceForm(CustomFieldModelForm):
+class ServiceForm(NetBoxModelForm):
     device = DynamicModelChoiceField(
         queryset=Device.objects.all(),
         required=False

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

@@ -1,6 +1,7 @@
 from django import forms
 
 from utilities.forms import BootstrapMixin
+from .base import *
 
 OBJ_TYPE_CHOICES = (
     ('', '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.
 
-    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'
     filterset = None
     table = None
-    form = None
 
     def get_required_permission(self):
         return get_permission_for_model(self.queryset.model, 'delete')
@@ -694,9 +693,6 @@ class BulkDeleteView(GetReturnURLMixin, BaseMultiObjectView):
         class BulkDeleteForm(ConfirmationForm):
             pk = ModelMultipleChoiceField(queryset=self.queryset, widget=MultipleHiddenInput)
 
-        if self.form:
-            return self.form
-
         return BulkDeleteForm
 
     #

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

@@ -33,7 +33,7 @@
         {% csrf_token %}
 
         {% block form %}
-          {% if form.Meta.fieldsets %}
+          {% if form.fieldsets %}
 
             {# Render hidden fields #}
             {% for field in form.hidden_fields %}
@@ -41,7 +41,7 @@
             {% endfor %}
 
             {# 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="row mb-2">
                   <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 %}
         {{ field }}
       {% 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 %}
-        {% 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 #}
         {% for field in filter_form.visible_fields %}
           {% if not filter_form.custom_fields or field.name not in filter_form.custom_fields %}
@@ -30,7 +31,7 @@
             </div>
           {% endif %}
         {% endfor %}
-      {% endif %}
+      {% endfor %}
       {% if filter_form.custom_fields %}
         {# List all custom field filters #}
         <hr class="card-divider mt-0" />

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

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

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

@@ -1,6 +1,6 @@
 from django import forms
 
-from extras.forms import AddRemoveTagsForm, CustomFieldModelBulkEditForm
+from netbox.forms import NetBoxModelBulkEditForm
 from tenancy.models import *
 from utilities.forms import DynamicModelChoiceField
 
@@ -17,7 +17,7 @@ __all__ = (
 # Tenants
 #
 
-class TenantGroupBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class TenantGroupBulkEditForm(NetBoxModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
         queryset=TenantGroup.objects.all(),
         widget=forms.MultipleHiddenInput
@@ -31,11 +31,10 @@ class TenantGroupBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
         required=False
     )
 
-    class Meta:
-        nullable_fields = ['parent', 'description']
+    nullable_fields = ('parent', 'description')
 
 
-class TenantBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class TenantBulkEditForm(NetBoxModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
         queryset=Tenant.objects.all(),
         widget=forms.MultipleHiddenInput()
@@ -45,17 +44,14 @@ class TenantBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
         required=False
     )
 
-    class Meta:
-        nullable_fields = [
-            'group',
-        ]
+    nullable_fields = ('group',)
 
 
 #
 # Contacts
 #
 
-class ContactGroupBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class ContactGroupBulkEditForm(NetBoxModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
         queryset=ContactGroup.objects.all(),
         widget=forms.MultipleHiddenInput
@@ -69,11 +65,10 @@ class ContactGroupBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
         required=False
     )
 
-    class Meta:
-        nullable_fields = ['parent', 'description']
+    nullable_fields = ('parent', 'description')
 
 
-class ContactRoleBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class ContactRoleBulkEditForm(NetBoxModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
         queryset=ContactRole.objects.all(),
         widget=forms.MultipleHiddenInput
@@ -83,11 +78,10 @@ class ContactRoleBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
         required=False
     )
 
-    class Meta:
-        nullable_fields = ['description']
+    nullable_fields = ('description',)
 
 
-class ContactBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class ContactBulkEditForm(NetBoxModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
         queryset=Contact.objects.all(),
         widget=forms.MultipleHiddenInput()
@@ -112,5 +106,4 @@ class ContactBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
         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 utilities.forms import CSVModelChoiceField, SlugField
 
@@ -15,7 +15,7 @@ __all__ = (
 # Tenants
 #
 
-class TenantGroupCSVForm(CustomFieldModelCSVForm):
+class TenantGroupCSVForm(NetBoxModelCSVForm):
     parent = CSVModelChoiceField(
         queryset=TenantGroup.objects.all(),
         required=False,
@@ -29,7 +29,7 @@ class TenantGroupCSVForm(CustomFieldModelCSVForm):
         fields = ('name', 'slug', 'parent', 'description')
 
 
-class TenantCSVForm(CustomFieldModelCSVForm):
+class TenantCSVForm(NetBoxModelCSVForm):
     slug = SlugField()
     group = CSVModelChoiceField(
         queryset=TenantGroup.objects.all(),
@@ -47,7 +47,7 @@ class TenantCSVForm(CustomFieldModelCSVForm):
 # Contacts
 #
 
-class ContactGroupCSVForm(CustomFieldModelCSVForm):
+class ContactGroupCSVForm(NetBoxModelCSVForm):
     parent = CSVModelChoiceField(
         queryset=ContactGroup.objects.all(),
         required=False,
@@ -61,7 +61,7 @@ class ContactGroupCSVForm(CustomFieldModelCSVForm):
         fields = ('name', 'slug', 'parent', 'description')
 
 
-class ContactRoleCSVForm(CustomFieldModelCSVForm):
+class ContactRoleCSVForm(NetBoxModelCSVForm):
     slug = SlugField()
 
     class Meta:
@@ -69,7 +69,7 @@ class ContactRoleCSVForm(CustomFieldModelCSVForm):
         fields = ('name', 'slug', 'description')
 
 
-class ContactCSVForm(CustomFieldModelCSVForm):
+class ContactCSVForm(NetBoxModelCSVForm):
     group = CSVModelChoiceField(
         queryset=ContactGroup.objects.all(),
         required=False,

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

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

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

@@ -1,7 +1,7 @@
 from django import forms
 
-from extras.forms import CustomFieldModelForm
 from extras.models import Tag
+from netbox.forms import NetBoxModelForm
 from tenancy.models import *
 from utilities.forms import (
     BootstrapMixin, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField, SmallTextarea,
@@ -22,7 +22,7 @@ __all__ = (
 # Tenants
 #
 
-class TenantGroupForm(CustomFieldModelForm):
+class TenantGroupForm(NetBoxModelForm):
     parent = DynamicModelChoiceField(
         queryset=TenantGroup.objects.all(),
         required=False
@@ -40,7 +40,7 @@ class TenantGroupForm(CustomFieldModelForm):
         ]
 
 
-class TenantForm(CustomFieldModelForm):
+class TenantForm(NetBoxModelForm):
     slug = SlugField()
     group = DynamicModelChoiceField(
         queryset=TenantGroup.objects.all(),
@@ -52,21 +52,22 @@ class TenantForm(CustomFieldModelForm):
         required=False
     )
 
+    fieldsets = (
+        ('Tenant', ('name', 'slug', 'group', 'description', 'tags')),
+    )
+
     class Meta:
         model = Tenant
         fields = (
             'name', 'slug', 'group', 'description', 'comments', 'tags',
         )
-        fieldsets = (
-            ('Tenant', ('name', 'slug', 'group', 'description', 'tags')),
-        )
 
 
 #
 # Contacts
 #
 
-class ContactGroupForm(CustomFieldModelForm):
+class ContactGroupForm(NetBoxModelForm):
     parent = DynamicModelChoiceField(
         queryset=ContactGroup.objects.all(),
         required=False
@@ -82,7 +83,7 @@ class ContactGroupForm(CustomFieldModelForm):
         fields = ('parent', 'name', 'slug', 'description', 'tags')
 
 
-class ContactRoleForm(CustomFieldModelForm):
+class ContactRoleForm(NetBoxModelForm):
     slug = SlugField()
     tags = DynamicModelMultipleChoiceField(
         queryset=Tag.objects.all(),
@@ -94,7 +95,7 @@ class ContactRoleForm(CustomFieldModelForm):
         fields = ('name', 'slug', 'description', 'tags')
 
 
-class ContactForm(CustomFieldModelForm):
+class ContactForm(NetBoxModelForm):
     group = DynamicModelChoiceField(
         queryset=ContactGroup.objects.all(),
         required=False
@@ -105,14 +106,15 @@ class ContactForm(CustomFieldModelForm):
         required=False
     )
 
+    fieldsets = (
+        ('Contact', ('group', 'name', 'title', 'phone', 'email', 'address', 'tags')),
+    )
+
     class Meta:
         model = Contact
         fields = (
             'group', 'name', 'title', 'phone', 'email', 'address', 'comments', 'tags',
         )
-        fieldsets = (
-            ('Contact', ('group', 'name', 'title', 'phone', 'email', 'address', 'tags')),
-        )
         widgets = {
             '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):
+    fieldsets = (
+        ('User Interface', (
+            'pagination.per_page',
+            'pagination.placement',
+            'ui.colormode',
+        )),
+        ('Miscellaneous', (
+            'data_format',
+        )),
+    )
 
     class Meta:
         model = UserConfig
         fields = ()
-        fieldsets = (
-            ('User Interface', (
-                'pagination.per_page',
-                'pagination.placement',
-                'ui.colormode',
-            )),
-            ('Miscellaneous', (
-                'data_format',
-            )),
-        )
 
     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__ = (
     'BootstrapMixin',
     'BulkEditForm',
-    'BulkEditBaseForm',
+    'BulkEditMixin',
     'BulkRenameForm',
     'ConfirmationForm',
     'CSVModelForm',
@@ -21,6 +21,10 @@ __all__ = (
 )
 
 
+#
+# Mixins
+#
+
 class BootstrapMixin:
     """
     Add the base Bootstrap CSS classes to form elements.
@@ -61,6 +65,21 @@ class BootstrapMixin:
                 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):
     """
     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)
 
 
-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
 
 

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

@@ -3,8 +3,8 @@ from django import forms
 from dcim.choices import InterfaceModeChoices
 from dcim.constants import INTERFACE_MTU_MAX, INTERFACE_MTU_MIN
 from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup
-from extras.forms import AddRemoveTagsForm, CustomFieldModelBulkEditForm
 from ipam.models import VLAN
+from netbox.forms import NetBoxModelBulkEditForm
 from tenancy.models import Tenant
 from utilities.forms import (
     add_blank_choice, BulkEditNullBooleanSelect, BulkRenameForm, CommentField, DynamicModelChoiceField,
@@ -23,7 +23,7 @@ __all__ = (
 )
 
 
-class ClusterTypeBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class ClusterTypeBulkEditForm(NetBoxModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
         queryset=ClusterType.objects.all(),
         widget=forms.MultipleHiddenInput
@@ -33,11 +33,10 @@ class ClusterTypeBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
         required=False
     )
 
-    class Meta:
-        nullable_fields = ['description']
+    nullable_fields = ('description',)
 
 
-class ClusterGroupBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class ClusterGroupBulkEditForm(NetBoxModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
         queryset=ClusterGroup.objects.all(),
         widget=forms.MultipleHiddenInput
@@ -47,11 +46,10 @@ class ClusterGroupBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
         required=False
     )
 
-    class Meta:
-        nullable_fields = ['description']
+    nullable_fields = ('description',)
 
 
-class ClusterBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class ClusterBulkEditForm(NetBoxModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
         queryset=Cluster.objects.all(),
         widget=forms.MultipleHiddenInput()
@@ -89,13 +87,12 @@ class ClusterBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
         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(
         queryset=VirtualMachine.objects.all(),
         widget=forms.MultipleHiddenInput()
@@ -144,13 +141,12 @@ class VirtualMachineBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm
         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(
         queryset=VMInterface.objects.all(),
         widget=forms.MultipleHiddenInput()
@@ -197,10 +193,9 @@ class VMInterfaceBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
         required=False
     )
 
-    class Meta:
-        nullable_fields = [
-            'parent', 'bridge', 'mtu', 'description',
-        ]
+    nullable_fields = (
+        'parent', 'bridge', 'mtu', 'description',
+    )
 
     def __init__(self, *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.models import DeviceRole, Platform, Site
-from extras.forms import CustomFieldModelCSVForm
+from netbox.forms import NetBoxModelCSVForm
 from tenancy.models import Tenant
 from utilities.forms import CSVChoiceField, CSVModelChoiceField, SlugField
 from virtualization.choices import *
@@ -15,7 +15,7 @@ __all__ = (
 )
 
 
-class ClusterTypeCSVForm(CustomFieldModelCSVForm):
+class ClusterTypeCSVForm(NetBoxModelCSVForm):
     slug = SlugField()
 
     class Meta:
@@ -23,7 +23,7 @@ class ClusterTypeCSVForm(CustomFieldModelCSVForm):
         fields = ('name', 'slug', 'description')
 
 
-class ClusterGroupCSVForm(CustomFieldModelCSVForm):
+class ClusterGroupCSVForm(NetBoxModelCSVForm):
     slug = SlugField()
 
     class Meta:
@@ -31,7 +31,7 @@ class ClusterGroupCSVForm(CustomFieldModelCSVForm):
         fields = ('name', 'slug', 'description')
 
 
-class ClusterCSVForm(CustomFieldModelCSVForm):
+class ClusterCSVForm(NetBoxModelCSVForm):
     type = CSVModelChoiceField(
         queryset=ClusterType.objects.all(),
         to_field_name='name',
@@ -61,7 +61,7 @@ class ClusterCSVForm(CustomFieldModelCSVForm):
         fields = ('name', 'type', 'group', 'site', 'comments')
 
 
-class VirtualMachineCSVForm(CustomFieldModelCSVForm):
+class VirtualMachineCSVForm(NetBoxModelCSVForm):
     status = CSVChoiceField(
         choices=VirtualMachineStatusChoices,
         help_text='Operational status of device'
@@ -99,7 +99,7 @@ class VirtualMachineCSVForm(CustomFieldModelCSVForm):
         )
 
 
-class VMInterfaceCSVForm(CustomFieldModelCSVForm):
+class VMInterfaceCSVForm(NetBoxModelCSVForm):
     virtual_machine = CSVModelChoiceField(
         queryset=VirtualMachine.objects.all(),
         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 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 utilities.forms import (
     DynamicModelMultipleChoiceField, StaticSelect, StaticSelectMultiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES,
@@ -19,24 +20,24 @@ __all__ = (
 )
 
 
-class ClusterTypeFilterForm(CustomFieldModelFilterForm):
+class ClusterTypeFilterForm(NetBoxModelFilterSetForm):
     model = ClusterType
     tag = TagFilterField(model)
 
 
-class ClusterGroupFilterForm(CustomFieldModelFilterForm):
+class ClusterGroupFilterForm(NetBoxModelFilterSetForm):
     model = ClusterGroup
     tag = TagFilterField(model)
 
 
-class ClusterFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
+class ClusterFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
     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(
         queryset=ClusterType.objects.all(),
         required=False,
@@ -71,15 +72,15 @@ class ClusterFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
     tag = TagFilterField(model)
 
 
-class VirtualMachineFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, CustomFieldModelFilterForm):
+class VirtualMachineFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, NetBoxModelFilterSetForm):
     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(
         queryset=ClusterGroup.objects.all(),
         required=False,
@@ -151,13 +152,13 @@ class VirtualMachineFilterForm(LocalConfigContextFilterForm, TenancyFilterForm,
     tag = TagFilterField(model)
 
 
-class VMInterfaceFilterForm(CustomFieldModelFilterForm):
+class VMInterfaceFilterForm(NetBoxModelFilterSetForm):
     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(
         queryset=Cluster.objects.all(),
         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.models import INTERFACE_MODE_HELP_TEXT
 from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site, SiteGroup
-from extras.forms import CustomFieldModelForm
 from extras.models import Tag
 from ipam.models import IPAddress, VLAN, VLANGroup
+from netbox.forms import NetBoxModelForm
 from tenancy.forms import TenancyForm
 from utilities.forms import (
     BootstrapMixin, CommentField, ConfirmationForm, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
@@ -26,7 +26,7 @@ __all__ = (
 )
 
 
-class ClusterTypeForm(CustomFieldModelForm):
+class ClusterTypeForm(NetBoxModelForm):
     slug = SlugField()
     tags = DynamicModelMultipleChoiceField(
         queryset=Tag.objects.all(),
@@ -40,7 +40,7 @@ class ClusterTypeForm(CustomFieldModelForm):
         )
 
 
-class ClusterGroupForm(CustomFieldModelForm):
+class ClusterGroupForm(NetBoxModelForm):
     slug = SlugField()
     tags = DynamicModelMultipleChoiceField(
         queryset=Tag.objects.all(),
@@ -54,7 +54,7 @@ class ClusterGroupForm(CustomFieldModelForm):
         )
 
 
-class ClusterForm(TenancyForm, CustomFieldModelForm):
+class ClusterForm(TenancyForm, NetBoxModelForm):
     type = DynamicModelChoiceField(
         queryset=ClusterType.objects.all()
     )
@@ -90,15 +90,16 @@ class ClusterForm(TenancyForm, CustomFieldModelForm):
         required=False
     )
 
+    fieldsets = (
+        ('Cluster', ('name', 'type', 'group', 'region', 'site_group', 'site', 'tags')),
+        ('Tenancy', ('tenant_group', 'tenant')),
+    )
+
     class Meta:
         model = Cluster
         fields = (
             'name', 'type', 'group', 'tenant', 'region', 'site_group', 'site', 'comments', 'tags',
         )
-        fieldsets = (
-            ('Cluster', ('name', 'type', 'group', 'region', 'site_group', 'site', 'tags')),
-            ('Tenancy', ('tenant_group', 'tenant')),
-        )
 
 
 class ClusterAddDevicesForm(BootstrapMixin, forms.Form):
@@ -171,7 +172,7 @@ class ClusterRemoveDevicesForm(ConfirmationForm):
     )
 
 
-class VirtualMachineForm(TenancyForm, CustomFieldModelForm):
+class VirtualMachineForm(TenancyForm, NetBoxModelForm):
     cluster_group = DynamicModelChoiceField(
         queryset=ClusterGroup.objects.all(),
         required=False,
@@ -206,20 +207,21 @@ class VirtualMachineForm(TenancyForm, CustomFieldModelForm):
         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:
         model = VirtualMachine
         fields = [
             'name', 'status', 'cluster_group', 'cluster', 'role', 'tenant_group', 'tenant', 'platform', 'primary_ip4',
             'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'tags', 'local_context_data',
         ]
-        fieldsets = (
-            ('Virtual Machine', ('name', 'role', 'status', 'tags')),
-            ('Cluster', ('cluster_group', 'cluster')),
-            ('Tenancy', ('tenant_group', 'tenant')),
-            ('Management', ('platform', 'primary_ip4', 'primary_ip6')),
-            ('Resources', ('vcpus', 'memory', 'disk')),
-            ('Config Context', ('local_context_data',)),
-        )
         help_texts = {
             'local_context_data': "Local config context data overwrites all sources contexts in the final rendered "
                                   "config context",
@@ -271,7 +273,7 @@ class VirtualMachineForm(TenancyForm, CustomFieldModelForm):
             self.fields['primary_ip6'].widget.attrs['readonly'] = True
 
 
-class VMInterfaceForm(InterfaceCommonForm, CustomFieldModelForm):
+class VMInterfaceForm(InterfaceCommonForm, NetBoxModelForm):
     parent = DynamicModelChoiceField(
         queryset=VMInterface.objects.all(),
         required=False,

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

@@ -1,8 +1,8 @@
 from django import forms
 
 from dcim.choices import LinkStatusChoices
-from extras.forms import AddRemoveTagsForm, CustomFieldModelBulkEditForm
 from ipam.models import VLAN
+from netbox.forms import NetBoxModelBulkEditForm
 from utilities.forms import add_blank_choice, DynamicModelChoiceField
 from wireless.choices import *
 from wireless.constants import SSID_MAX_LENGTH
@@ -15,7 +15,7 @@ __all__ = (
 )
 
 
-class WirelessLANGroupBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class WirelessLANGroupBulkEditForm(NetBoxModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
         queryset=WirelessLANGroup.objects.all(),
         widget=forms.MultipleHiddenInput
@@ -29,11 +29,10 @@ class WirelessLANGroupBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditFo
         required=False
     )
 
-    class Meta:
-        nullable_fields = ['parent', 'description']
+    nullable_fields = ('parent', 'description')
 
 
-class WirelessLANBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class WirelessLANBulkEditForm(NetBoxModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
         queryset=WirelessLAN.objects.all(),
         widget=forms.MultipleHiddenInput
@@ -68,11 +67,12 @@ class WirelessLANBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
         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(
         queryset=WirelessLink.objects.all(),
         widget=forms.MultipleHiddenInput
@@ -102,5 +102,6 @@ class WirelessLinkBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
         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.models import Interface
-from extras.forms import CustomFieldModelCSVForm
 from ipam.models import VLAN
+from netbox.forms import NetBoxModelCSVForm
 from utilities.forms import CSVChoiceField, CSVModelChoiceField, SlugField
 from wireless.choices import *
 from wireless.models import *
@@ -13,7 +13,7 @@ __all__ = (
 )
 
 
-class WirelessLANGroupCSVForm(CustomFieldModelCSVForm):
+class WirelessLANGroupCSVForm(NetBoxModelCSVForm):
     parent = CSVModelChoiceField(
         queryset=WirelessLANGroup.objects.all(),
         required=False,
@@ -27,7 +27,7 @@ class WirelessLANGroupCSVForm(CustomFieldModelCSVForm):
         fields = ('name', 'slug', 'parent', 'description')
 
 
-class WirelessLANCSVForm(CustomFieldModelCSVForm):
+class WirelessLANCSVForm(NetBoxModelCSVForm):
     group = CSVModelChoiceField(
         queryset=WirelessLANGroup.objects.all(),
         required=False,
@@ -56,7 +56,7 @@ class WirelessLANCSVForm(CustomFieldModelCSVForm):
         fields = ('ssid', 'group', 'description', 'vlan', 'auth_type', 'auth_cipher', 'auth_psk')
 
 
-class WirelessLinkCSVForm(CustomFieldModelCSVForm):
+class WirelessLinkCSVForm(NetBoxModelCSVForm):
     status = CSVChoiceField(
         choices=LinkStatusChoices,
         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 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 wireless.choices import *
 from wireless.models import *
@@ -14,7 +14,7 @@ __all__ = (
 )
 
 
-class WirelessLANGroupFilterForm(CustomFieldModelFilterForm):
+class WirelessLANGroupFilterForm(NetBoxModelFilterSetForm):
     model = WirelessLANGroup
     parent_id = DynamicModelMultipleChoiceField(
         queryset=WirelessLANGroup.objects.all(),
@@ -24,12 +24,13 @@ class WirelessLANGroupFilterForm(CustomFieldModelFilterForm):
     tag = TagFilterField(model)
 
 
-class WirelessLANFilterForm(CustomFieldModelFilterForm):
+class WirelessLANFilterForm(NetBoxModelFilterSetForm):
     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(
         required=False,
         label='SSID'
@@ -56,7 +57,7 @@ class WirelessLANFilterForm(CustomFieldModelFilterForm):
     tag = TagFilterField(model)
 
 
-class WirelessLinkFilterForm(CustomFieldModelFilterForm):
+class WirelessLinkFilterForm(NetBoxModelFilterSetForm):
     model = WirelessLink
     ssid = forms.CharField(
         required=False,

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

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