Browse Source

Merge branch 'develop' into 4051-disable-makemigrations

Jeremy Stretch 6 years ago
parent
commit
ac27759250

+ 2 - 1
docs/installation/3-http-daemon.md

@@ -107,9 +107,10 @@ Install gunicorn:
 # pip3 install gunicorn
 # pip3 install gunicorn
 ```
 ```
 
 
-Copy `contrib/gunicorn.py` to `/opt/netbox/gunicorn.py`. We make a copy of this file to ensure that any changes to it do not get overwritten by a future upgrade.
+Copy `/opt/netbox/contrib/gunicorn.py` to `/opt/netbox/gunicorn.py`. We make a copy of this file to ensure that any changes to it do not get overwritten by a future upgrade.
 
 
 ```no-highlight
 ```no-highlight
+# cd /opt/netbox
 # cp contrib/gunicorn.py /opt/netbox/gunicorn.py
 # cp contrib/gunicorn.py /opt/netbox/gunicorn.py
 ```
 ```
 
 

+ 4 - 0
docs/release-notes/version-2.7.md

@@ -2,12 +2,16 @@
 
 
 ## Enhancements
 ## Enhancements
 
 
+* [#568](https://github.com/netbox-community/netbox/issues/568) - Allow custom fields to be imported and exported using CSV
 * [#4051](https://github.com/netbox-community/netbox/issues/4051) - Disable the `makemigrations` management command
 * [#4051](https://github.com/netbox-community/netbox/issues/4051) - Disable the `makemigrations` management command
 
 
 ## Bug Fixes
 ## Bug Fixes
 
 
+* [#4030](https://github.com/netbox-community/netbox/issues/4030) - Fix exception when bulk editing interfaces (revised)
 * [#4043](https://github.com/netbox-community/netbox/issues/4043) - Fix toggling of required fields in custom scripts
 * [#4043](https://github.com/netbox-community/netbox/issues/4043) - Fix toggling of required fields in custom scripts
 * [#4049](https://github.com/netbox-community/netbox/issues/4049) - Restore missing `tags` field in IPAM service serializer
 * [#4049](https://github.com/netbox-community/netbox/issues/4049) - Restore missing `tags` field in IPAM service serializer
+* [#4052](https://github.com/netbox-community/netbox/issues/4052) - Fix error when bulk importing interfaces to virtual machines
+* [#4056](https://github.com/netbox-community/netbox/issues/4056) - Repair schema migration for Rack.outer_unit (from #3569)
 
 
 ---
 ---
 
 

+ 7 - 5
netbox/circuits/forms.py

@@ -2,7 +2,9 @@ from django import forms
 from taggit.forms import TagField
 from taggit.forms import TagField
 
 
 from dcim.models import Region, Site
 from dcim.models import Region, Site
-from extras.forms import AddRemoveTagsForm, CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
+from extras.forms import (
+    AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldFilterForm, CustomFieldModelForm, CustomFieldModelCSVForm,
+)
 from tenancy.forms import TenancyFilterForm, TenancyForm
 from tenancy.forms import TenancyFilterForm, TenancyForm
 from tenancy.models import Tenant
 from tenancy.models import Tenant
 from utilities.forms import (
 from utilities.forms import (
@@ -17,7 +19,7 @@ from .models import Circuit, CircuitTermination, CircuitType, Provider
 # Providers
 # Providers
 #
 #
 
 
-class ProviderForm(BootstrapMixin, CustomFieldForm):
+class ProviderForm(BootstrapMixin, CustomFieldModelForm):
     slug = SlugField()
     slug = SlugField()
     comments = CommentField()
     comments = CommentField()
     tags = TagField(
     tags = TagField(
@@ -46,7 +48,7 @@ class ProviderForm(BootstrapMixin, CustomFieldForm):
         }
         }
 
 
 
 
-class ProviderCSVForm(forms.ModelForm):
+class ProviderCSVForm(CustomFieldModelCSVForm):
     slug = SlugField()
     slug = SlugField()
 
 
     class Meta:
     class Meta:
@@ -160,7 +162,7 @@ class CircuitTypeCSVForm(forms.ModelForm):
 # Circuits
 # Circuits
 #
 #
 
 
-class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldForm):
+class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
     comments = CommentField()
     comments = CommentField()
     tags = TagField(
     tags = TagField(
         required=False
         required=False
@@ -188,7 +190,7 @@ class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldForm):
         }
         }
 
 
 
 
-class CircuitCSVForm(forms.ModelForm):
+class CircuitCSVForm(CustomFieldModelCSVForm):
     provider = forms.ModelChoiceField(
     provider = forms.ModelChoiceField(
         queryset=Provider.objects.all(),
         queryset=Provider.objects.all(),
         to_field_name='name',
         to_field_name='name',

+ 12 - 11
netbox/dcim/forms.py

@@ -13,7 +13,8 @@ from timezone_field import TimeZoneFormField
 
 
 from circuits.models import Circuit, Provider
 from circuits.models import Circuit, Provider
 from extras.forms import (
 from extras.forms import (
-    AddRemoveTagsForm, CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm, LocalConfigContextFilterForm
+    AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldFilterForm, CustomFieldModelForm,
+    LocalConfigContextFilterForm,
 )
 )
 from ipam.constants import BGP_ASN_MAX, BGP_ASN_MIN
 from ipam.constants import BGP_ASN_MAX, BGP_ASN_MIN
 from ipam.models import IPAddress, VLAN
 from ipam.models import IPAddress, VLAN
@@ -215,7 +216,7 @@ class RegionFilterForm(BootstrapMixin, forms.Form):
 # Sites
 # Sites
 #
 #
 
 
-class SiteForm(BootstrapMixin, TenancyForm, CustomFieldForm):
+class SiteForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
     region = TreeNodeChoiceField(
     region = TreeNodeChoiceField(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
         required=False,
         required=False,
@@ -263,7 +264,7 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldForm):
         }
         }
 
 
 
 
-class SiteCSVForm(forms.ModelForm):
+class SiteCSVForm(CustomFieldModelCSVForm):
     status = CSVChoiceField(
     status = CSVChoiceField(
         choices=SiteStatusChoices,
         choices=SiteStatusChoices,
         required=False,
         required=False,
@@ -459,7 +460,7 @@ class RackRoleCSVForm(forms.ModelForm):
 # Racks
 # Racks
 #
 #
 
 
-class RackForm(BootstrapMixin, TenancyForm, CustomFieldForm):
+class RackForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
     group = ChainedModelChoiceField(
     group = ChainedModelChoiceField(
         queryset=RackGroup.objects.all(),
         queryset=RackGroup.objects.all(),
         chains=(
         chains=(
@@ -504,7 +505,7 @@ class RackForm(BootstrapMixin, TenancyForm, CustomFieldForm):
         }
         }
 
 
 
 
-class RackCSVForm(forms.ModelForm):
+class RackCSVForm(CustomFieldModelCSVForm):
     site = forms.ModelChoiceField(
     site = forms.ModelChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         to_field_name='name',
         to_field_name='name',
@@ -897,7 +898,7 @@ class ManufacturerCSVForm(forms.ModelForm):
 # Device types
 # Device types
 #
 #
 
 
-class DeviceTypeForm(BootstrapMixin, CustomFieldForm):
+class DeviceTypeForm(BootstrapMixin, CustomFieldModelForm):
     slug = SlugField(
     slug = SlugField(
         slug_source='model'
         slug_source='model'
     )
     )
@@ -1516,7 +1517,7 @@ class PlatformCSVForm(forms.ModelForm):
 # Devices
 # Devices
 #
 #
 
 
-class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm):
+class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
     site = forms.ModelChoiceField(
     site = forms.ModelChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         widget=APISelect(
         widget=APISelect(
@@ -1724,7 +1725,7 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm):
             self.initial['rack'] = self.instance.parent_bay.device.rack_id
             self.initial['rack'] = self.instance.parent_bay.device.rack_id
 
 
 
 
-class BaseDeviceCSVForm(forms.ModelForm):
+class BaseDeviceCSVForm(CustomFieldModelCSVForm):
     device_role = forms.ModelChoiceField(
     device_role = forms.ModelChoiceField(
         queryset=DeviceRole.objects.all(),
         queryset=DeviceRole.objects.all(),
         to_field_name='name',
         to_field_name='name',
@@ -2726,7 +2727,7 @@ class InterfaceCSVForm(forms.ModelForm):
         super().__init__(*args, **kwargs)
         super().__init__(*args, **kwargs)
 
 
         # Limit LAG choices to interfaces belonging to this device (or VC master)
         # Limit LAG choices to interfaces belonging to this device (or VC master)
-        if self.is_bound:
+        if self.is_bound and 'device' in self.data:
             try:
             try:
                 device = self.fields['device'].to_python(self.data['device'])
                 device = self.fields['device'].to_python(self.data['device'])
             except forms.ValidationError:
             except forms.ValidationError:
@@ -4241,7 +4242,7 @@ class PowerPanelFilterForm(BootstrapMixin, CustomFieldFilterForm):
 # Power feeds
 # Power feeds
 #
 #
 
 
-class PowerFeedForm(BootstrapMixin, CustomFieldForm):
+class PowerFeedForm(BootstrapMixin, CustomFieldModelForm):
     site = ChainedModelChoiceField(
     site = ChainedModelChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         required=False,
         required=False,
@@ -4286,7 +4287,7 @@ class PowerFeedForm(BootstrapMixin, CustomFieldForm):
             self.initial['site'] = self.instance.power_panel.site
             self.initial['site'] = self.instance.power_panel.site
 
 
 
 
-class PowerFeedCSVForm(forms.ModelForm):
+class PowerFeedCSVForm(CustomFieldModelCSVForm):
     site = forms.ModelChoiceField(
     site = forms.ModelChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         to_field_name='name',
         to_field_name='name',

+ 1 - 1
netbox/dcim/migrations/0079_3569_rack_fields.py

@@ -37,7 +37,7 @@ def rack_status_to_slug(apps, schema_editor):
 def rack_outer_unit_to_slug(apps, schema_editor):
 def rack_outer_unit_to_slug(apps, schema_editor):
     Rack = apps.get_model('dcim', 'Rack')
     Rack = apps.get_model('dcim', 'Rack')
     for id, slug in RACK_DIMENSION_CHOICES:
     for id, slug in RACK_DIMENSION_CHOICES:
-        Rack.objects.filter(status=str(id)).update(status=slug)
+        Rack.objects.filter(outer_unit=str(id)).update(outer_unit=slug)
 
 
 
 
 class Migration(migrations.Migration):
 class Migration(migrations.Migration):

+ 27 - 0
netbox/dcim/migrations/0092_fix_rack_outer_unit.py

@@ -0,0 +1,27 @@
+from django.db import migrations
+
+RACK_DIMENSION_CHOICES = (
+    (1000, 'mm'),
+    (2000, 'in'),
+)
+
+
+def rack_outer_unit_to_slug(apps, schema_editor):
+    Rack = apps.get_model('dcim', 'Rack')
+    for id, slug in RACK_DIMENSION_CHOICES:
+        Rack.objects.filter(outer_unit=str(id)).update(outer_unit=slug)
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0091_interface_type_other'),
+    ]
+
+    operations = [
+        # Fixes a missed field migration from #3569; see bug #4056. The original migration has also been fixed,
+        # so this can be omitted when squashing in the future.
+        migrations.RunPython(
+            code=rack_outer_unit_to_slug
+        ),
+    ]

+ 1 - 1
netbox/dcim/models/device_components.py

@@ -676,7 +676,7 @@ class Interface(CableTermination, ComponentModel):
             self.untagged_vlan = None
             self.untagged_vlan = None
 
 
         # Only "tagged" interfaces may have tagged VLANs assigned. ("tagged all" implies all VLANs are assigned.)
         # Only "tagged" interfaces may have tagged VLANs assigned. ("tagged all" implies all VLANs are assigned.)
-        if self.pk and self.mode is not InterfaceModeChoices.MODE_TAGGED:
+        if self.pk and self.mode != InterfaceModeChoices.MODE_TAGGED:
             self.tagged_vlans.clear()
             self.tagged_vlans.clear()
 
 
         return super().save(*args, **kwargs)
         return super().save(*args, **kwargs)

+ 50 - 101
netbox/extras/forms.py

@@ -1,17 +1,14 @@
-from collections import OrderedDict
-
 from django import forms
 from django import forms
 from django.contrib.auth.models import User
 from django.contrib.auth.models import User
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
-from django.core.exceptions import ObjectDoesNotExist
 from taggit.forms import TagField
 from taggit.forms import TagField
 
 
 from dcim.models import DeviceRole, Platform, Region, Site
 from dcim.models import DeviceRole, Platform, Region, Site
 from tenancy.models import Tenant, TenantGroup
 from tenancy.models import Tenant, TenantGroup
 from utilities.forms import (
 from utilities.forms import (
     add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorSelect,
     add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorSelect,
-    CommentField, ContentTypeSelect, DatePicker, DateTimePicker, FilterChoiceField, LaxURLField, JSONField,
-    SlugField, StaticSelect2, BOOLEAN_WITH_BLANK_CHOICES,
+    CommentField, ContentTypeSelect, DateTimePicker, FilterChoiceField, JSONField, SlugField, StaticSelect2,
+    BOOLEAN_WITH_BLANK_CHOICES,
 )
 )
 from .choices import *
 from .choices import *
 from .models import ConfigContext, CustomField, CustomFieldValue, ImageAttachment, ObjectChange, Tag
 from .models import ConfigContext, CustomField, CustomFieldValue, ImageAttachment, ObjectChange, Tag
@@ -21,102 +18,41 @@ from .models import ConfigContext, CustomField, CustomFieldValue, ImageAttachmen
 # Custom fields
 # Custom fields
 #
 #
 
 
-def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=False):
-    """
-    Retrieve all CustomFields applicable to the given ContentType
-    """
-    field_dict = OrderedDict()
-    custom_fields = CustomField.objects.filter(obj_type=content_type)
-    if filterable_only:
-        custom_fields = custom_fields.exclude(filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED)
-
-    for cf in custom_fields:
-        field_name = 'cf_{}'.format(str(cf.name))
-        initial = cf.default if not bulk_edit else None
-
-        # Integer
-        if cf.type == CustomFieldTypeChoices.TYPE_INTEGER:
-            field = forms.IntegerField(required=cf.required, initial=initial)
-
-        # Boolean
-        elif cf.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
-            choices = (
-                (None, '---------'),
-                (1, 'True'),
-                (0, 'False'),
-            )
-            if initial is not None and initial.lower() in ['true', 'yes', '1']:
-                initial = 1
-            elif initial is not None and initial.lower() in ['false', 'no', '0']:
-                initial = 0
-            else:
-                initial = None
-            field = forms.NullBooleanField(
-                required=cf.required, initial=initial, widget=StaticSelect2(choices=choices)
-            )
-
-        # Date
-        elif cf.type == CustomFieldTypeChoices.TYPE_DATE:
-            field = forms.DateField(required=cf.required, initial=initial, widget=DatePicker())
-
-        # Select
-        elif cf.type == CustomFieldTypeChoices.TYPE_SELECT:
-            choices = [(cfc.pk, cfc) for cfc in cf.choices.all()]
-            if not cf.required or bulk_edit or filterable_only:
-                choices = [(None, '---------')] + choices
-            # Check for a default choice
-            default_choice = None
-            if initial:
-                try:
-                    default_choice = cf.choices.get(value=initial).pk
-                except ObjectDoesNotExist:
-                    pass
-            field = forms.TypedChoiceField(
-                choices=choices, coerce=int, required=cf.required, initial=default_choice, widget=StaticSelect2()
-            )
-
-        # URL
-        elif cf.type == CustomFieldTypeChoices.TYPE_URL:
-            field = LaxURLField(required=cf.required, initial=initial)
-
-        # Text
-        else:
-            field = forms.CharField(max_length=255, required=cf.required, initial=initial)
-
-        field.model = cf
-        field.label = cf.label if cf.label else cf.name.replace('_', ' ').capitalize()
-        if cf.description:
-            field.help_text = cf.description
-
-        field_dict[field_name] = field
-
-    return field_dict
-
-
-class CustomFieldForm(forms.ModelForm):
+class CustomFieldModelForm(forms.ModelForm):
 
 
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
 
 
-        self.custom_fields = []
         self.obj_type = ContentType.objects.get_for_model(self._meta.model)
         self.obj_type = ContentType.objects.get_for_model(self._meta.model)
+        self.custom_fields = []
+        self.custom_field_values = {}
 
 
         super().__init__(*args, **kwargs)
         super().__init__(*args, **kwargs)
 
 
-        # Add all applicable CustomFields to the form
-        custom_fields = []
-        for name, field in get_custom_fields_for_model(self.obj_type).items():
-            self.fields[name] = field
-            custom_fields.append(name)
-        self.custom_fields = custom_fields
+        self._append_customfield_fields()
 
 
-        # If editing an existing object, initialize values for all custom fields
+    def _append_customfield_fields(self):
+        """
+        Append form fields for all CustomFields assigned to this model.
+        """
+        # Retrieve initial CustomField values for the instance
         if self.instance.pk:
         if self.instance.pk:
-            existing_values = CustomFieldValue.objects.filter(
+            for cfv in CustomFieldValue.objects.filter(
                 obj_type=self.obj_type,
                 obj_type=self.obj_type,
                 obj_id=self.instance.pk
                 obj_id=self.instance.pk
-            ).prefetch_related('field')
-            for cfv in existing_values:
-                self.initial['cf_{}'.format(str(cfv.field.name))] = cfv.serialized_value
+            ).prefetch_related('field'):
+                self.custom_field_values[cfv.field.name] = cfv.serialized_value
+
+        # Append form fields; assign initial values if modifying and existing object
+        for cf in CustomField.objects.filter(obj_type=self.obj_type):
+            field_name = 'cf_{}'.format(cf.name)
+            if self.instance.pk:
+                self.fields[field_name] = cf.to_form_field(set_initial=False)
+                self.fields[field_name].initial = self.custom_field_values.get(cf.name)
+            else:
+                self.fields[field_name] = cf.to_form_field()
+
+            # Annotate the field in the list of CustomField form fields
+            self.custom_fields.append(field_name)
 
 
     def _save_custom_fields(self):
     def _save_custom_fields(self):
 
 
@@ -151,6 +87,19 @@ class CustomFieldForm(forms.ModelForm):
         return obj
         return obj
 
 
 
 
+class CustomFieldModelCSVForm(CustomFieldModelForm):
+
+    def _append_customfield_fields(self):
+
+        # Append form fields
+        for cf in CustomField.objects.filter(obj_type=self.obj_type):
+            field_name = 'cf_{}'.format(cf.name)
+            self.fields[field_name] = cf.to_form_field(for_csv_import=True)
+
+            # Annotate the field in the list of CustomField form fields
+            self.custom_fields.append(field_name)
+
+
 class CustomFieldBulkEditForm(BulkEditForm):
 class CustomFieldBulkEditForm(BulkEditForm):
 
 
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
@@ -160,15 +109,14 @@ class CustomFieldBulkEditForm(BulkEditForm):
         self.obj_type = ContentType.objects.get_for_model(self.model)
         self.obj_type = ContentType.objects.get_for_model(self.model)
 
 
         # Add all applicable CustomFields to the form
         # Add all applicable CustomFields to the form
-        custom_fields = get_custom_fields_for_model(self.obj_type, bulk_edit=True).items()
-        for name, field in custom_fields:
+        custom_fields = CustomField.objects.filter(obj_type=self.obj_type)
+        for cf in custom_fields:
             # Annotate non-required custom fields as nullable
             # Annotate non-required custom fields as nullable
-            if not field.required:
-                self.nullable_fields.append(name)
-            field.required = False
-            self.fields[name] = field
+            if not cf.required:
+                self.nullable_fields.append(cf.name)
+            self.fields[cf.name] = cf.to_form_field(set_initial=False, enforce_required=False)
             # Annotate this as a custom field
             # Annotate this as a custom field
-            self.custom_fields.append(name)
+            self.custom_fields.append(cf.name)
 
 
 
 
 class CustomFieldFilterForm(forms.Form):
 class CustomFieldFilterForm(forms.Form):
@@ -180,10 +128,11 @@ class CustomFieldFilterForm(forms.Form):
         super().__init__(*args, **kwargs)
         super().__init__(*args, **kwargs)
 
 
         # Add all applicable CustomFields to the form
         # Add all applicable CustomFields to the form
-        custom_fields = get_custom_fields_for_model(self.obj_type, filterable_only=True).items()
-        for name, field in custom_fields:
-            field.required = False
-            self.fields[name] = field
+        custom_fields = CustomField.objects.filter(obj_type=self.obj_type).exclude(
+            filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED
+        )
+        for cf in custom_fields:
+            self.fields[cf.name] = cf.to_form_field(set_initial=True, enforce_required=False)
 
 
 
 
 #
 #

+ 71 - 0
netbox/extras/models.py

@@ -1,6 +1,7 @@
 from collections import OrderedDict
 from collections import OrderedDict
 from datetime import date
 from datetime import date
 
 
+from django import forms
 from django.contrib.auth.models import User
 from django.contrib.auth.models import User
 from django.contrib.contenttypes.fields import GenericForeignKey
 from django.contrib.contenttypes.fields import GenericForeignKey
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
@@ -14,6 +15,7 @@ from django.utils.text import slugify
 from taggit.models import TagBase, GenericTaggedItemBase
 from taggit.models import TagBase, GenericTaggedItemBase
 
 
 from utilities.fields import ColorField
 from utilities.fields import ColorField
+from utilities.forms import CSVChoiceField, DatePicker, LaxURLField, StaticSelect2, add_blank_choice
 from utilities.utils import deepmerge, render_jinja2
 from utilities.utils import deepmerge, render_jinja2
 from .choices import *
 from .choices import *
 from .constants import *
 from .constants import *
@@ -280,6 +282,75 @@ class CustomField(models.Model):
             return self.choices.get(pk=int(serialized_value))
             return self.choices.get(pk=int(serialized_value))
         return serialized_value
         return serialized_value
 
 
+    def to_form_field(self, set_initial=True, enforce_required=True, for_csv_import=False):
+        """
+        Return a form field suitable for setting a CustomField's value for an object.
+
+        set_initial: Set initial date for the field. This should be False when generating a field for bulk editing.
+        enforce_required: Honor the value of CustomField.required. Set to False for filtering/bulk editing.
+        for_csv_import: Return a form field suitable for bulk import of objects in CSV format.
+        """
+        initial = self.default if set_initial else None
+        required = self.required if enforce_required else False
+
+        # Integer
+        if self.type == CustomFieldTypeChoices.TYPE_INTEGER:
+            field = forms.IntegerField(required=required, initial=initial)
+
+        # Boolean
+        elif self.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
+            choices = (
+                (None, '---------'),
+                (1, 'True'),
+                (0, 'False'),
+            )
+            if initial is not None and initial.lower() in ['true', 'yes', '1']:
+                initial = 1
+            elif initial is not None and initial.lower() in ['false', 'no', '0']:
+                initial = 0
+            else:
+                initial = None
+            field = forms.NullBooleanField(
+                required=required, initial=initial, widget=StaticSelect2(choices=choices)
+            )
+
+        # Date
+        elif self.type == CustomFieldTypeChoices.TYPE_DATE:
+            field = forms.DateField(required=required, initial=initial, widget=DatePicker())
+
+        # Select
+        elif self.type == CustomFieldTypeChoices.TYPE_SELECT:
+            choices = [(cfc.pk, cfc.value) for cfc in self.choices.all()]
+
+            if not required:
+                choices = add_blank_choice(choices)
+
+            # Set the initial value to the PK of the default choice, if any
+            if set_initial:
+                default_choice = self.choices.filter(value=self.default).first()
+                if default_choice:
+                    initial = default_choice.pk
+
+            field_class = CSVChoiceField if for_csv_import else forms.ChoiceField
+            field = field_class(
+                choices=choices, required=required, initial=initial, widget=StaticSelect2()
+            )
+
+        # URL
+        elif self.type == CustomFieldTypeChoices.TYPE_URL:
+            field = LaxURLField(required=required, initial=initial)
+
+        # Text
+        else:
+            field = forms.CharField(max_length=255, required=required, initial=initial)
+
+        field.model = self
+        field.label = self.label if self.label else self.name.replace('_', ' ').capitalize()
+        if self.description:
+            field.help_text = self.description
+
+        return field
+
 
 
 class CustomFieldValue(models.Model):
 class CustomFieldValue(models.Model):
     field = models.ForeignKey(
     field = models.ForeignKey(

+ 113 - 2
netbox/extras/tests/test_customfields.py

@@ -1,14 +1,15 @@
 from datetime import date
 from datetime import date
 
 
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
-from django.test import TestCase
+from django.test import Client, TestCase
 from django.urls import reverse
 from django.urls import reverse
 from rest_framework import status
 from rest_framework import status
 
 
+from dcim.forms import SiteCSVForm
 from dcim.models import Site
 from dcim.models import Site
 from extras.choices import *
 from extras.choices import *
 from extras.models import CustomField, CustomFieldValue, CustomFieldChoice
 from extras.models import CustomField, CustomFieldValue, CustomFieldChoice
-from utilities.testing import APITestCase
+from utilities.testing import APITestCase, create_test_user
 from virtualization.models import VirtualMachine
 from virtualization.models import VirtualMachine
 
 
 
 
@@ -364,3 +365,113 @@ class CustomFieldChoiceAPITest(APITestCase):
         self.assertEqual(self.cf_choice_1.pk, response.data[self.cf_1.name][self.cf_choice_1.value])
         self.assertEqual(self.cf_choice_1.pk, response.data[self.cf_1.name][self.cf_choice_1.value])
         self.assertEqual(self.cf_choice_2.pk, response.data[self.cf_1.name][self.cf_choice_2.value])
         self.assertEqual(self.cf_choice_2.pk, response.data[self.cf_1.name][self.cf_choice_2.value])
         self.assertEqual(self.cf_choice_3.pk, response.data[self.cf_2.name][self.cf_choice_3.value])
         self.assertEqual(self.cf_choice_3.pk, response.data[self.cf_2.name][self.cf_choice_3.value])
+
+
+class CustomFieldImportTest(TestCase):
+
+    def setUp(self):
+
+        user = create_test_user(
+            permissions=[
+                'dcim.view_site',
+                'dcim.add_site',
+            ]
+        )
+        self.client = Client()
+        self.client.force_login(user)
+
+    @classmethod
+    def setUpTestData(cls):
+
+        custom_fields = (
+            CustomField(name='text', type=CustomFieldTypeChoices.TYPE_TEXT),
+            CustomField(name='integer', type=CustomFieldTypeChoices.TYPE_INTEGER),
+            CustomField(name='boolean', type=CustomFieldTypeChoices.TYPE_BOOLEAN),
+            CustomField(name='date', type=CustomFieldTypeChoices.TYPE_DATE),
+            CustomField(name='url', type=CustomFieldTypeChoices.TYPE_URL),
+            CustomField(name='select', type=CustomFieldTypeChoices.TYPE_SELECT),
+        )
+        for cf in custom_fields:
+            cf.save()
+            cf.obj_type.set([ContentType.objects.get_for_model(Site)])
+
+        CustomFieldChoice.objects.bulk_create((
+            CustomFieldChoice(field=custom_fields[5], value='Choice A'),
+            CustomFieldChoice(field=custom_fields[5], value='Choice B'),
+            CustomFieldChoice(field=custom_fields[5], value='Choice C'),
+        ))
+
+    def test_import(self):
+        """
+        Import a Site in CSV format, including a value for each CustomField.
+        """
+        data = (
+            ('name', 'slug', 'cf_text', 'cf_integer', 'cf_boolean', 'cf_date', 'cf_url', 'cf_select'),
+            ('Site 1', 'site-1', 'ABC', '123', 'True', '2020-01-01', 'http://example.com/1', 'Choice A'),
+            ('Site 2', 'site-2', 'DEF', '456', 'False', '2020-01-02', 'http://example.com/2', 'Choice B'),
+            ('Site 3', 'site-3', '', '', '', '', '', ''),
+        )
+        csv_data = '\n'.join(','.join(row) for row in data)
+
+        response = self.client.post(reverse('dcim:site_import'), {'csv': csv_data})
+        self.assertEqual(response.status_code, 200)
+
+        # Validate data for site 1
+        custom_field_values = {
+            cf.name: value for cf, value in Site.objects.get(name='Site 1').get_custom_fields().items()
+        }
+        self.assertEqual(len(custom_field_values), 6)
+        self.assertEqual(custom_field_values['text'], 'ABC')
+        self.assertEqual(custom_field_values['integer'], 123)
+        self.assertEqual(custom_field_values['boolean'], True)
+        self.assertEqual(custom_field_values['date'], date(2020, 1, 1))
+        self.assertEqual(custom_field_values['url'], 'http://example.com/1')
+        self.assertEqual(custom_field_values['select'].value, 'Choice A')
+
+        # Validate data for site 2
+        custom_field_values = {
+            cf.name: value for cf, value in Site.objects.get(name='Site 2').get_custom_fields().items()
+        }
+        self.assertEqual(len(custom_field_values), 6)
+        self.assertEqual(custom_field_values['text'], 'DEF')
+        self.assertEqual(custom_field_values['integer'], 456)
+        self.assertEqual(custom_field_values['boolean'], False)
+        self.assertEqual(custom_field_values['date'], date(2020, 1, 2))
+        self.assertEqual(custom_field_values['url'], 'http://example.com/2')
+        self.assertEqual(custom_field_values['select'].value, 'Choice B')
+
+        # No CustomFieldValues should be created for site 3
+        obj_type = ContentType.objects.get_for_model(Site)
+        site3 = Site.objects.get(name='Site 3')
+        self.assertFalse(CustomFieldValue.objects.filter(obj_type=obj_type, obj_id=site3.pk).exists())
+        self.assertEqual(CustomFieldValue.objects.count(), 12)  # Sanity check
+
+    def test_import_missing_required(self):
+        """
+        Attempt to import an object missing a required custom field.
+        """
+        # Set one of our CustomFields to required
+        CustomField.objects.filter(name='text').update(required=True)
+
+        form_data = {
+            'name': 'Site 1',
+            'slug': 'site-1',
+        }
+
+        form = SiteCSVForm(data=form_data)
+        self.assertFalse(form.is_valid())
+        self.assertIn('cf_text', form.errors)
+
+    def test_import_invalid_choice(self):
+        """
+        Attempt to import an object with an invalid choice selection.
+        """
+        form_data = {
+            'name': 'Site 1',
+            'slug': 'site-1',
+            'cf_select': 'Choice X'
+        }
+
+        form = SiteCSVForm(data=form_data)
+        self.assertFalse(form.is_valid())
+        self.assertIn('cf_select', form.errors)

+ 15 - 13
netbox/ipam/forms.py

@@ -4,7 +4,9 @@ from django.core.validators import MaxValueValidator, MinValueValidator
 from taggit.forms import TagField
 from taggit.forms import TagField
 
 
 from dcim.models import Device, Interface, Rack, Region, Site
 from dcim.models import Device, Interface, Rack, Region, Site
-from extras.forms import AddRemoveTagsForm, CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
+from extras.forms import (
+    AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldModelForm, CustomFieldFilterForm,
+)
 from tenancy.forms import TenancyFilterForm, TenancyForm
 from tenancy.forms import TenancyFilterForm, TenancyForm
 from tenancy.models import Tenant
 from tenancy.models import Tenant
 from utilities.forms import (
 from utilities.forms import (
@@ -31,7 +33,7 @@ IPADDRESS_MASK_LENGTH_CHOICES = add_blank_choice([
 # VRFs
 # VRFs
 #
 #
 
 
-class VRFForm(BootstrapMixin, TenancyForm, CustomFieldForm):
+class VRFForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
     tags = TagField(
     tags = TagField(
         required=False
         required=False
     )
     )
@@ -49,7 +51,7 @@ class VRFForm(BootstrapMixin, TenancyForm, CustomFieldForm):
         }
         }
 
 
 
 
-class VRFCSVForm(forms.ModelForm):
+class VRFCSVForm(CustomFieldModelCSVForm):
     tenant = forms.ModelChoiceField(
     tenant = forms.ModelChoiceField(
         queryset=Tenant.objects.all(),
         queryset=Tenant.objects.all(),
         required=False,
         required=False,
@@ -144,7 +146,7 @@ class RIRFilterForm(BootstrapMixin, forms.Form):
 # Aggregates
 # Aggregates
 #
 #
 
 
-class AggregateForm(BootstrapMixin, CustomFieldForm):
+class AggregateForm(BootstrapMixin, CustomFieldModelForm):
     tags = TagField(
     tags = TagField(
         required=False
         required=False
     )
     )
@@ -166,7 +168,7 @@ class AggregateForm(BootstrapMixin, CustomFieldForm):
         }
         }
 
 
 
 
-class AggregateCSVForm(forms.ModelForm):
+class AggregateCSVForm(CustomFieldModelCSVForm):
     rir = forms.ModelChoiceField(
     rir = forms.ModelChoiceField(
         queryset=RIR.objects.all(),
         queryset=RIR.objects.all(),
         to_field_name='name',
         to_field_name='name',
@@ -263,7 +265,7 @@ class RoleCSVForm(forms.ModelForm):
 # Prefixes
 # Prefixes
 #
 #
 
 
-class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldForm):
+class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
     site = forms.ModelChoiceField(
     site = forms.ModelChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         required=False,
         required=False,
@@ -341,7 +343,7 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldForm):
         self.fields['vrf'].empty_label = 'Global'
         self.fields['vrf'].empty_label = 'Global'
 
 
 
 
-class PrefixCSVForm(forms.ModelForm):
+class PrefixCSVForm(CustomFieldModelCSVForm):
     vrf = FlexibleModelChoiceField(
     vrf = FlexibleModelChoiceField(
         queryset=VRF.objects.all(),
         queryset=VRF.objects.all(),
         to_field_name='rd',
         to_field_name='rd',
@@ -584,7 +586,7 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm)
 # IP addresses
 # IP addresses
 #
 #
 
 
-class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm):
+class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModelForm):
     interface = forms.ModelChoiceField(
     interface = forms.ModelChoiceField(
         queryset=Interface.objects.all(),
         queryset=Interface.objects.all(),
         required=False
         required=False
@@ -751,7 +753,7 @@ class IPAddressBulkCreateForm(BootstrapMixin, forms.Form):
     )
     )
 
 
 
 
-class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldForm):
+class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
 
 
     class Meta:
     class Meta:
         model = IPAddress
         model = IPAddress
@@ -771,7 +773,7 @@ class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldForm):
         self.fields['vrf'].empty_label = 'Global'
         self.fields['vrf'].empty_label = 'Global'
 
 
 
 
-class IPAddressCSVForm(forms.ModelForm):
+class IPAddressCSVForm(CustomFieldModelCSVForm):
     vrf = FlexibleModelChoiceField(
     vrf = FlexibleModelChoiceField(
         queryset=VRF.objects.all(),
         queryset=VRF.objects.all(),
         to_field_name='rd',
         to_field_name='rd',
@@ -1087,7 +1089,7 @@ class VLANGroupFilterForm(BootstrapMixin, forms.Form):
 # VLANs
 # VLANs
 #
 #
 
 
-class VLANForm(BootstrapMixin, TenancyForm, CustomFieldForm):
+class VLANForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
     site = forms.ModelChoiceField(
     site = forms.ModelChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         required=False,
         required=False,
@@ -1135,7 +1137,7 @@ class VLANForm(BootstrapMixin, TenancyForm, CustomFieldForm):
         }
         }
 
 
 
 
-class VLANCSVForm(forms.ModelForm):
+class VLANCSVForm(CustomFieldModelCSVForm):
     site = forms.ModelChoiceField(
     site = forms.ModelChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         required=False,
         required=False,
@@ -1310,7 +1312,7 @@ class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
 # Services
 # Services
 #
 #
 
 
-class ServiceForm(BootstrapMixin, CustomFieldForm):
+class ServiceForm(BootstrapMixin, CustomFieldModelForm):
     port = forms.IntegerField(
     port = forms.IntegerField(
         min_value=SERVICE_PORT_MIN,
         min_value=SERVICE_PORT_MIN,
         max_value=SERVICE_PORT_MAX
         max_value=SERVICE_PORT_MAX

+ 5 - 3
netbox/secrets/forms.py

@@ -4,7 +4,9 @@ from django import forms
 from taggit.forms import TagField
 from taggit.forms import TagField
 
 
 from dcim.models import Device
 from dcim.models import Device
-from extras.forms import AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldFilterForm, CustomFieldForm
+from extras.forms import (
+    AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldFilterForm, CustomFieldModelForm, CustomFieldModelCSVForm,
+)
 from utilities.forms import (
 from utilities.forms import (
     APISelect, APISelectMultiple, BootstrapMixin, FilterChoiceField, FlexibleModelChoiceField, SlugField,
     APISelect, APISelectMultiple, BootstrapMixin, FilterChoiceField, FlexibleModelChoiceField, SlugField,
     StaticSelect2Multiple
     StaticSelect2Multiple
@@ -68,7 +70,7 @@ class SecretRoleCSVForm(forms.ModelForm):
 # Secrets
 # Secrets
 #
 #
 
 
-class SecretForm(BootstrapMixin, CustomFieldForm):
+class SecretForm(BootstrapMixin, CustomFieldModelForm):
     plaintext = forms.CharField(
     plaintext = forms.CharField(
         max_length=SECRET_PLAINTEXT_MAX_LENGTH,
         max_length=SECRET_PLAINTEXT_MAX_LENGTH,
         required=False,
         required=False,
@@ -116,7 +118,7 @@ class SecretForm(BootstrapMixin, CustomFieldForm):
             })
             })
 
 
 
 
-class SecretCSVForm(forms.ModelForm):
+class SecretCSVForm(CustomFieldModelCSVForm):
     device = FlexibleModelChoiceField(
     device = FlexibleModelChoiceField(
         queryset=Device.objects.all(),
         queryset=Device.objects.all(),
         to_field_name='name',
         to_field_name='name',

+ 2 - 0
netbox/templates/virtualization/virtualmachine.html

@@ -265,7 +265,9 @@
                         <th>Name</th>
                         <th>Name</th>
                         <th>LAG</th>
                         <th>LAG</th>
                         <th>Description</th>
                         <th>Description</th>
+                        <th>MTU</th>
                         <th>Mode</th>
                         <th>Mode</th>
+                        <th>Cable</th>
                         <th colspan="2">Connection</th>
                         <th colspan="2">Connection</th>
                         <th></th>
                         <th></th>
                     </tr>
                     </tr>

+ 5 - 3
netbox/tenancy/forms.py

@@ -1,7 +1,9 @@
 from django import forms
 from django import forms
 from taggit.forms import TagField
 from taggit.forms import TagField
 
 
-from extras.forms import AddRemoveTagsForm, CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
+from extras.forms import (
+    AddRemoveTagsForm, CustomFieldModelForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldFilterForm,
+)
 from utilities.forms import (
 from utilities.forms import (
     APISelect, APISelectMultiple, BootstrapMixin, ChainedFieldsMixin, ChainedModelChoiceField, CommentField,
     APISelect, APISelectMultiple, BootstrapMixin, ChainedFieldsMixin, ChainedModelChoiceField, CommentField,
     FilterChoiceField, SlugField,
     FilterChoiceField, SlugField,
@@ -38,7 +40,7 @@ class TenantGroupCSVForm(forms.ModelForm):
 # Tenants
 # Tenants
 #
 #
 
 
-class TenantForm(BootstrapMixin, CustomFieldForm):
+class TenantForm(BootstrapMixin, CustomFieldModelForm):
     slug = SlugField()
     slug = SlugField()
     comments = CommentField()
     comments = CommentField()
     tags = TagField(
     tags = TagField(
@@ -57,7 +59,7 @@ class TenantForm(BootstrapMixin, CustomFieldForm):
         }
         }
 
 
 
 
-class TenantCSVForm(forms.ModelForm):
+class TenantCSVForm(CustomFieldModelForm):
     slug = SlugField()
     slug = SlugField()
     group = forms.ModelChoiceField(
     group = forms.ModelChoiceField(
         queryset=TenantGroup.objects.all(),
         queryset=TenantGroup.objects.all(),

+ 36 - 12
netbox/utilities/views.py

@@ -6,7 +6,7 @@ from django.contrib import messages
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ValidationError
 from django.core.exceptions import ValidationError
 from django.db import transaction, IntegrityError
 from django.db import transaction, IntegrityError
-from django.db.models import Count, ProtectedError
+from django.db.models import Count, ManyToManyField, ProtectedError
 from django.db.models.query import QuerySet
 from django.db.models.query import QuerySet
 from django.forms import CharField, Form, ModelMultipleChoiceField, MultipleHiddenInput, Textarea
 from django.forms import CharField, Form, ModelMultipleChoiceField, MultipleHiddenInput, Textarea
 from django.http import HttpResponse, HttpResponseServerError
 from django.http import HttpResponse, HttpResponseServerError
@@ -88,15 +88,27 @@ class ObjectListView(View):
         Export the queryset of objects as comma-separated value (CSV), using the model's to_csv() method.
         Export the queryset of objects as comma-separated value (CSV), using the model's to_csv() method.
         """
         """
         csv_data = []
         csv_data = []
+        custom_fields = []
 
 
         # Start with the column headers
         # Start with the column headers
-        headers = ','.join(self.queryset.model.csv_headers)
-        csv_data.append(headers)
+        headers = self.queryset.model.csv_headers.copy()
+
+        # Add custom field headers, if any
+        if hasattr(self.queryset.model, 'get_custom_fields'):
+            for custom_field in self.queryset.model().get_custom_fields():
+                headers.append(custom_field.name)
+                custom_fields.append(custom_field.name)
+
+        csv_data.append(','.join(headers))
 
 
         # Iterate through the queryset appending each object
         # Iterate through the queryset appending each object
         for obj in self.queryset:
         for obj in self.queryset:
-            data = csv_format(obj.to_csv())
-            csv_data.append(data)
+            data = obj.to_csv()
+
+            for custom_field in custom_fields:
+                data += (obj.cf.get(custom_field, ''),)
+
+            csv_data.append(csv_format(data))
 
 
         return '\n'.join(csv_data)
         return '\n'.join(csv_data)
 
 
@@ -638,7 +650,9 @@ class BulkEditView(GetReturnURLMixin, View):
             if form.is_valid():
             if form.is_valid():
 
 
                 custom_fields = form.custom_fields if hasattr(form, 'custom_fields') else []
                 custom_fields = form.custom_fields if hasattr(form, 'custom_fields') else []
-                standard_fields = [field for field in form.fields if field not in custom_fields and field != 'pk']
+                standard_fields = [
+                    field for field in form.fields if field not in custom_fields + ['pk', 'add_tags', 'remove_tags']
+                ]
                 nullified_fields = request.POST.getlist('_nullify')
                 nullified_fields = request.POST.getlist('_nullify')
 
 
                 try:
                 try:
@@ -650,14 +664,24 @@ class BulkEditView(GetReturnURLMixin, View):
 
 
                             # Update standard fields. If a field is listed in _nullify, delete its value.
                             # Update standard fields. If a field is listed in _nullify, delete its value.
                             for name in standard_fields:
                             for name in standard_fields:
-                                if name in form.nullable_fields and name in nullified_fields and isinstance(form.cleaned_data[name], QuerySet):
-                                    getattr(obj, name).set([])
-                                elif name in form.nullable_fields and name in nullified_fields:
-                                    setattr(obj, name, '' if isinstance(form.fields[name], CharField) else None)
-                                elif isinstance(form.cleaned_data[name], QuerySet) and form.cleaned_data[name]:
+
+                                model_field = model._meta.get_field(name)
+
+                                # Handle nullification
+                                if name in form.nullable_fields and name in nullified_fields:
+                                    if isinstance(model_field, ManyToManyField):
+                                        getattr(obj, name).set([])
+                                    else:
+                                        setattr(obj, name, None if model_field.null else '')
+
+                                # ManyToManyFields
+                                elif isinstance(model_field, ManyToManyField):
                                     getattr(obj, name).set(form.cleaned_data[name])
                                     getattr(obj, name).set(form.cleaned_data[name])
-                                elif form.cleaned_data[name] not in (None, '') and not isinstance(form.cleaned_data[name], QuerySet):
+
+                                # Normal fields
+                                elif form.cleaned_data[name] not in (None, ''):
                                     setattr(obj, name, form.cleaned_data[name])
                                     setattr(obj, name, form.cleaned_data[name])
+
                             obj.full_clean()
                             obj.full_clean()
                             obj.save()
                             obj.save()
 
 

+ 7 - 5
netbox/virtualization/forms.py

@@ -6,7 +6,9 @@ from dcim.choices import InterfaceModeChoices
 from dcim.constants import INTERFACE_MTU_MAX, INTERFACE_MTU_MIN
 from dcim.constants import INTERFACE_MTU_MAX, INTERFACE_MTU_MIN
 from dcim.forms import INTERFACE_MODE_HELP_TEXT
 from dcim.forms import INTERFACE_MODE_HELP_TEXT
 from dcim.models import Device, DeviceRole, Interface, Platform, Rack, Region, Site
 from dcim.models import Device, DeviceRole, Interface, Platform, Rack, Region, Site
-from extras.forms import AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldForm, CustomFieldFilterForm
+from extras.forms import (
+    AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldModelForm, CustomFieldFilterForm,
+)
 from ipam.models import IPAddress, VLANGroup, VLAN
 from ipam.models import IPAddress, VLANGroup, VLAN
 from tenancy.forms import TenancyFilterForm, TenancyForm
 from tenancy.forms import TenancyFilterForm, TenancyForm
 from tenancy.models import Tenant
 from tenancy.models import Tenant
@@ -74,7 +76,7 @@ class ClusterGroupCSVForm(forms.ModelForm):
 # Clusters
 # Clusters
 #
 #
 
 
-class ClusterForm(BootstrapMixin, TenancyForm, CustomFieldForm):
+class ClusterForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
     comments = CommentField()
     comments = CommentField()
     tags = TagField(
     tags = TagField(
         required=False
         required=False
@@ -98,7 +100,7 @@ class ClusterForm(BootstrapMixin, TenancyForm, CustomFieldForm):
         }
         }
 
 
 
 
-class ClusterCSVForm(forms.ModelForm):
+class ClusterCSVForm(CustomFieldModelCSVForm):
     type = forms.ModelChoiceField(
     type = forms.ModelChoiceField(
         queryset=ClusterType.objects.all(),
         queryset=ClusterType.objects.all(),
         to_field_name='name',
         to_field_name='name',
@@ -327,7 +329,7 @@ class ClusterRemoveDevicesForm(ConfirmationForm):
 # Virtual Machines
 # Virtual Machines
 #
 #
 
 
-class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldForm):
+class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
     cluster_group = forms.ModelChoiceField(
     cluster_group = forms.ModelChoiceField(
         queryset=ClusterGroup.objects.all(),
         queryset=ClusterGroup.objects.all(),
         required=False,
         required=False,
@@ -430,7 +432,7 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldForm):
             self.fields['primary_ip6'].widget.attrs['readonly'] = True
             self.fields['primary_ip6'].widget.attrs['readonly'] = True
 
 
 
 
-class VirtualMachineCSVForm(forms.ModelForm):
+class VirtualMachineCSVForm(CustomFieldModelCSVForm):
     status = CSVChoiceField(
     status = CSVChoiceField(
         choices=VirtualMachineStatusChoices,
         choices=VirtualMachineStatusChoices,
         required=False,
         required=False,