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

Merge pull request #5145 from netbox-community/4878-custom-fields

4878 custom fields
Jeremy Stretch 5 лет назад
Родитель
Сommit
dbfb9b2cee
35 измененных файлов с 579 добавлено и 751 удалено
  1. 4 4
      docs/administration/netbox-shell.md
  2. 22 0
      netbox/circuits/migrations/0020_custom_field_data.py
  3. 0 11
      netbox/circuits/models.py
  4. 37 0
      netbox/dcim/migrations/0116_custom_field_data.py
  5. 0 10
      netbox/dcim/models/devices.py
  6. 0 6
      netbox/dcim/models/power.py
  7. 0 5
      netbox/dcim/models/racks.py
  8. 0 5
      netbox/dcim/models/sites.py
  9. 23 6
      netbox/extras/admin.py
  10. 31 101
      netbox/extras/api/customfields.py
  11. 0 3
      netbox/extras/api/urls.py
  12. 2 46
      netbox/extras/api/views.py
  13. 15 32
      netbox/extras/filters.py
  14. 5 49
      netbox/extras/forms.py
  15. 35 0
      netbox/extras/migrations/0050_customfield_add_choices.py
  16. 73 0
      netbox/extras/migrations/0051_migrate_customfields.py
  17. 17 0
      netbox/extras/migrations/0052_delete_customfieldchoice_customfieldvalue.py
  18. 1 3
      netbox/extras/models/__init__.py
  19. 50 156
      netbox/extras/models/customfields.py
  20. 1 19
      netbox/extras/querysets.py
  21. 26 1
      netbox/extras/signals.py
  22. 9 13
      netbox/extras/tests/test_changelog.py
  23. 101 177
      netbox/extras/tests/test_customfields.py
  24. 42 0
      netbox/ipam/migrations/0038_custom_field_data.py
  25. 1 31
      netbox/ipam/models.py
  26. 2 1
      netbox/netbox/settings.py
  27. 17 0
      netbox/secrets/migrations/0010_custom_field_data.py
  28. 0 6
      netbox/secrets/models.py
  29. 17 0
      netbox/tenancy/migrations/0010_custom_field_data.py
  30. 0 6
      netbox/tenancy/models.py
  31. 13 6
      netbox/utilities/custom_inspectors.py
  32. 3 5
      netbox/utilities/utils.py
  33. 10 39
      netbox/utilities/views.py
  34. 22 0
      netbox/virtualization/migrations/0018_custom_field_data.py
  35. 0 10
      netbox/virtualization/models.py

+ 4 - 4
docs/administration/netbox-shell.md

@@ -185,7 +185,7 @@ To delete an object, simply call `delete()` on its instance. This will return a
 >>> vlan
 >>> vlan
 <VLAN: 123 (BetterName)>
 <VLAN: 123 (BetterName)>
 >>> vlan.delete()
 >>> vlan.delete()
-(1, {'extras.CustomFieldValue': 0, 'ipam.VLAN': 1})
+(1, {'ipam.VLAN': 1})
 ```
 ```
 
 
 To delete multiple objects at once, call `delete()` on a filtered queryset. It's a good idea to always sanity-check the count of selected objects _before_ deleting them.
 To delete multiple objects at once, call `delete()` on a filtered queryset. It's a good idea to always sanity-check the count of selected objects _before_ deleting them.
@@ -194,9 +194,9 @@ To delete multiple objects at once, call `delete()` on a filtered queryset. It's
 >>> Device.objects.filter(name__icontains='test').count()
 >>> Device.objects.filter(name__icontains='test').count()
 27
 27
 >>> Device.objects.filter(name__icontains='test').delete()
 >>> Device.objects.filter(name__icontains='test').delete()
-(35, {'extras.CustomFieldValue': 0, 'dcim.DeviceBay': 0, 'secrets.Secret': 0,
-'dcim.InterfaceConnection': 4, 'extras.ImageAttachment': 0, 'dcim.Device': 27,
-'dcim.Interface': 4, 'dcim.ConsolePort': 0, 'dcim.PowerPort': 0})
+(35, {'dcim.DeviceBay': 0, 'secrets.Secret': 0, 'dcim.InterfaceConnection': 4,
+'extras.ImageAttachment': 0, 'dcim.Device': 27, 'dcim.Interface': 4,
+'dcim.ConsolePort': 0, 'dcim.PowerPort': 0})
 ```
 ```
 
 
 !!! warning
 !!! warning

+ 22 - 0
netbox/circuits/migrations/0020_custom_field_data.py

@@ -0,0 +1,22 @@
+import django.core.serializers.json
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('circuits', '0019_nullbooleanfield_to_booleanfield'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='circuit',
+            name='custom_field_data',
+            field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder),
+        ),
+        migrations.AddField(
+            model_name='provider',
+            name='custom_field_data',
+            field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder),
+        ),
+    ]

+ 0 - 11
netbox/circuits/models.py

@@ -1,4 +1,3 @@
-from django.contrib.contenttypes.fields import GenericRelation
 from django.db import models
 from django.db import models
 from django.urls import reverse
 from django.urls import reverse
 from taggit.managers import TaggableManager
 from taggit.managers import TaggableManager
@@ -61,11 +60,6 @@ class Provider(ChangeLoggedModel, CustomFieldModel):
     comments = models.TextField(
     comments = models.TextField(
         blank=True
         blank=True
     )
     )
-    custom_field_values = GenericRelation(
-        to='extras.CustomFieldValue',
-        content_type_field='obj_type',
-        object_id_field='obj_id'
-    )
     tags = TaggableManager(through=TaggedItem)
     tags = TaggableManager(through=TaggedItem)
 
 
     objects = RestrictedQuerySet.as_manager()
     objects = RestrictedQuerySet.as_manager()
@@ -186,11 +180,6 @@ class Circuit(ChangeLoggedModel, CustomFieldModel):
     comments = models.TextField(
     comments = models.TextField(
         blank=True
         blank=True
     )
     )
-    custom_field_values = GenericRelation(
-        to='extras.CustomFieldValue',
-        content_type_field='obj_type',
-        object_id_field='obj_id'
-    )
 
 
     objects = CircuitQuerySet.as_manager()
     objects = CircuitQuerySet.as_manager()
     tags = TaggableManager(through=TaggedItem)
     tags = TaggableManager(through=TaggedItem)

+ 37 - 0
netbox/dcim/migrations/0116_custom_field_data.py

@@ -0,0 +1,37 @@
+import django.core.serializers.json
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0115_rackreservation_order'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='device',
+            name='custom_field_data',
+            field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder),
+        ),
+        migrations.AddField(
+            model_name='devicetype',
+            name='custom_field_data',
+            field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder),
+        ),
+        migrations.AddField(
+            model_name='powerfeed',
+            name='custom_field_data',
+            field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder),
+        ),
+        migrations.AddField(
+            model_name='rack',
+            name='custom_field_data',
+            field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder),
+        ),
+        migrations.AddField(
+            model_name='site',
+            name='custom_field_data',
+            field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder),
+        ),
+    ]

+ 0 - 10
netbox/dcim/models/devices.py

@@ -134,11 +134,6 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel):
     comments = models.TextField(
     comments = models.TextField(
         blank=True
         blank=True
     )
     )
-    custom_field_values = GenericRelation(
-        to='extras.CustomFieldValue',
-        content_type_field='obj_type',
-        object_id_field='obj_id'
-    )
     tags = TaggableManager(through=TaggedItem)
     tags = TaggableManager(through=TaggedItem)
 
 
     objects = RestrictedQuerySet.as_manager()
     objects = RestrictedQuerySet.as_manager()
@@ -584,11 +579,6 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
     comments = models.TextField(
     comments = models.TextField(
         blank=True
         blank=True
     )
     )
-    custom_field_values = GenericRelation(
-        to='extras.CustomFieldValue',
-        content_type_field='obj_type',
-        object_id_field='obj_id'
-    )
     images = GenericRelation(
     images = GenericRelation(
         to='extras.ImageAttachment'
         to='extras.ImageAttachment'
     )
     )

+ 0 - 6
netbox/dcim/models/power.py

@@ -1,4 +1,3 @@
-from django.contrib.contenttypes.fields import GenericRelation
 from django.core.exceptions import ValidationError
 from django.core.exceptions import ValidationError
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.db import models
 from django.db import models
@@ -144,11 +143,6 @@ class PowerFeed(ChangeLoggedModel, CableTermination, CustomFieldModel):
     comments = models.TextField(
     comments = models.TextField(
         blank=True
         blank=True
     )
     )
-    custom_field_values = GenericRelation(
-        to='extras.CustomFieldValue',
-        content_type_field='obj_type',
-        object_id_field='obj_id'
-    )
     tags = TaggableManager(through=TaggedItem)
     tags = TaggableManager(through=TaggedItem)
 
 
     objects = RestrictedQuerySet.as_manager()
     objects = RestrictedQuerySet.as_manager()

+ 0 - 5
netbox/dcim/models/racks.py

@@ -261,11 +261,6 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
     comments = models.TextField(
     comments = models.TextField(
         blank=True
         blank=True
     )
     )
-    custom_field_values = GenericRelation(
-        to='extras.CustomFieldValue',
-        content_type_field='obj_type',
-        object_id_field='obj_id'
-    )
     images = GenericRelation(
     images = GenericRelation(
         to='extras.ImageAttachment'
         to='extras.ImageAttachment'
     )
     )

+ 0 - 5
netbox/dcim/models/sites.py

@@ -183,11 +183,6 @@ class Site(ChangeLoggedModel, CustomFieldModel):
     comments = models.TextField(
     comments = models.TextField(
         blank=True
         blank=True
     )
     )
-    custom_field_values = GenericRelation(
-        to='extras.CustomFieldValue',
-        content_type_field='obj_type',
-        object_id_field='obj_id'
-    )
     images = GenericRelation(
     images = GenericRelation(
         to='extras.ImageAttachment'
         to='extras.ImageAttachment'
     )
     )

+ 23 - 6
netbox/extras/admin.py

@@ -2,7 +2,8 @@ from django import forms
 from django.contrib import admin
 from django.contrib import admin
 
 
 from utilities.forms import LaxURLField
 from utilities.forms import LaxURLField
-from .models import CustomField, CustomFieldChoice, CustomLink, ExportTemplate, JobResult, Webhook
+from .choices import CustomFieldTypeChoices
+from .models import CustomField, CustomLink, ExportTemplate, JobResult, Webhook
 
 
 
 
 def order_content_types(field):
 def order_content_types(field):
@@ -80,22 +81,38 @@ class CustomFieldForm(forms.ModelForm):
 
 
         order_content_types(self.fields['obj_type'])
         order_content_types(self.fields['obj_type'])
 
 
+    def clean(self):
 
 
-class CustomFieldChoiceAdmin(admin.TabularInline):
-    model = CustomFieldChoice
-    extra = 5
+        # Validate selection choices
+        if self.cleaned_data['type'] == CustomFieldTypeChoices.TYPE_SELECT and len(self.cleaned_data['choices']) < 2:
+            raise forms.ValidationError({
+                'choices': 'Selection fields must specify at least two choices.'
+            })
 
 
 
 
 @admin.register(CustomField)
 @admin.register(CustomField)
 class CustomFieldAdmin(admin.ModelAdmin):
 class CustomFieldAdmin(admin.ModelAdmin):
-    inlines = [CustomFieldChoiceAdmin]
+    actions = None
+    form = CustomFieldForm
     list_display = [
     list_display = [
         'name', 'models', 'type', 'required', 'filter_logic', 'default', 'weight', 'description',
         'name', 'models', 'type', 'required', 'filter_logic', 'default', 'weight', 'description',
     ]
     ]
     list_filter = [
     list_filter = [
         'type', 'required', 'obj_type',
         'type', 'required', 'obj_type',
     ]
     ]
-    form = CustomFieldForm
+    fieldsets = (
+        ('Custom Field', {
+            'fields': ('type', 'name', 'weight', 'label', 'description', 'required', 'default', 'filter_logic')
+        }),
+        ('Assignment', {
+            'description': 'A custom field must be assigned to one or more object types.',
+            'fields': ('obj_type',)
+        }),
+        ('Choices', {
+            'description': 'A selection field must have two or more choices assigned to it.',
+            'fields': ('choices',)
+        })
+    )
 
 
     def models(self, obj):
     def models(self, obj):
         return ', '.join([ct.name for ct in obj.obj_type.all()])
         return ', '.join([ct.name for ct in obj.obj_type.all()])

+ 31 - 101
netbox/extras/api/customfields.py

@@ -1,14 +1,11 @@
 from datetime import datetime
 from datetime import datetime
 
 
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
-from django.core.exceptions import ObjectDoesNotExist
-from django.db import transaction
-from rest_framework import serializers
 from rest_framework.exceptions import ValidationError
 from rest_framework.exceptions import ValidationError
-from rest_framework.fields import CreateOnlyDefault
+from rest_framework.fields import CreateOnlyDefault, Field
 
 
 from extras.choices import *
 from extras.choices import *
-from extras.models import CustomField, CustomFieldChoice, CustomFieldValue
+from extras.models import CustomField
 from utilities.api import ValidatedModelSerializer
 from utilities.api import ValidatedModelSerializer
 
 
 
 
@@ -38,12 +35,6 @@ class CustomFieldDefaultValues:
                 elif field.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
                 elif field.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
                     # TODO: Fix default value assignment for boolean custom fields
                     # TODO: Fix default value assignment for boolean custom fields
                     field_value = False if field.default.lower() == 'false' else bool(field.default)
                     field_value = False if field.default.lower() == 'false' else bool(field.default)
-                elif field.type == CustomFieldTypeChoices.TYPE_SELECT:
-                    try:
-                        field_value = field.choices.get(value=field.default).pk
-                    except ObjectDoesNotExist:
-                        # Invalid default value
-                        field_value = None
                 else:
                 else:
                     field_value = field.default
                     field_value = field.default
                 value[field.name] = field_value
                 value[field.name] = field_value
@@ -53,26 +44,35 @@ class CustomFieldDefaultValues:
         return value
         return value
 
 
 
 
-class CustomFieldsSerializer(serializers.BaseSerializer):
+class CustomFieldsDataField(Field):
+
+    def _get_custom_fields(self):
+        """
+        Cache CustomFields assigned to this model to avoid redundant database queries
+        """
+        if not hasattr(self, '_custom_fields'):
+            content_type = ContentType.objects.get_for_model(self.parent.Meta.model)
+            self._custom_fields = CustomField.objects.filter(obj_type=content_type)
+        return self._custom_fields
 
 
     def to_representation(self, obj):
     def to_representation(self, obj):
-        return obj
+        return {
+            cf.name: obj.get(cf.name) for cf in self._get_custom_fields()
+        }
 
 
     def to_internal_value(self, data):
     def to_internal_value(self, data):
+        # If updating an existing instance, start with existing custom_field_data
+        if self.parent.instance:
+            data = {**self.parent.instance.custom_field_data, **data}
 
 
-        content_type = ContentType.objects.get_for_model(self.parent.Meta.model)
-        custom_fields = {
-            field.name: field for field in CustomField.objects.filter(obj_type=content_type)
-        }
+        custom_fields = {field.name: field for field in self._get_custom_fields()}
 
 
         for field_name, value in data.items():
         for field_name, value in data.items():
 
 
             try:
             try:
                 cf = custom_fields[field_name]
                 cf = custom_fields[field_name]
             except KeyError:
             except KeyError:
-                raise ValidationError(
-                    "Invalid custom field for {} objects: {}".format(content_type, field_name)
-                )
+                raise ValidationError(f"Invalid custom field name: {field_name}")
 
 
             # Data validation
             # Data validation
             if value not in [None, '']:
             if value not in [None, '']:
@@ -82,15 +82,11 @@ class CustomFieldsSerializer(serializers.BaseSerializer):
                     try:
                     try:
                         int(value)
                         int(value)
                     except ValueError:
                     except ValueError:
-                        raise ValidationError(
-                            "Invalid value for integer field {}: {}".format(field_name, value)
-                        )
+                        raise ValidationError(f"Invalid value for integer field {field_name}: {value}")
 
 
                 # Validate boolean
                 # Validate boolean
                 if cf.type == CustomFieldTypeChoices.TYPE_BOOLEAN and value not in [True, False, 1, 0]:
                 if cf.type == CustomFieldTypeChoices.TYPE_BOOLEAN and value not in [True, False, 1, 0]:
-                    raise ValidationError(
-                        "Invalid value for boolean field {}: {}".format(field_name, value)
-                    )
+                    raise ValidationError(f"Invalid value for boolean field {field_name}: {value}")
 
 
                 # Validate date
                 # Validate date
                 if cf.type == CustomFieldTypeChoices.TYPE_DATE:
                 if cf.type == CustomFieldTypeChoices.TYPE_DATE:
@@ -98,25 +94,16 @@ class CustomFieldsSerializer(serializers.BaseSerializer):
                         datetime.strptime(value, '%Y-%m-%d')
                         datetime.strptime(value, '%Y-%m-%d')
                     except ValueError:
                     except ValueError:
                         raise ValidationError(
                         raise ValidationError(
-                            "Invalid date for field {}: {}. (Required format is YYYY-MM-DD.)".format(field_name, value)
+                            f"Invalid date for field {field_name}: {value}. (Required format is YYYY-MM-DD.)"
                         )
                         )
 
 
                 # Validate selected choice
                 # Validate selected choice
                 if cf.type == CustomFieldTypeChoices.TYPE_SELECT:
                 if cf.type == CustomFieldTypeChoices.TYPE_SELECT:
-                    try:
-                        value = int(value)
-                    except ValueError:
-                        raise ValidationError(
-                            "{}: Choice selections must be passed as integers.".format(field_name)
-                        )
-                    valid_choices = [c.pk for c in cf.choices.all()]
-                    if value not in valid_choices:
-                        raise ValidationError(
-                            "Invalid choice for field {}: {}".format(field_name, value)
-                        )
+                    if value not in cf.choices:
+                        raise ValidationError(f"Invalid choice for field {field_name}: {value}")
 
 
             elif cf.required:
             elif cf.required:
-                raise ValidationError("Required field {} cannot be empty.".format(field_name))
+                raise ValidationError(f"Required field {field_name} cannot be empty.")
 
 
         # Check for missing required fields
         # Check for missing required fields
         missing_fields = []
         missing_fields = []
@@ -133,8 +120,8 @@ class CustomFieldModelSerializer(ValidatedModelSerializer):
     """
     """
     Extends ModelSerializer to render any CustomFields and their values associated with an object.
     Extends ModelSerializer to render any CustomFields and their values associated with an object.
     """
     """
-    custom_fields = CustomFieldsSerializer(
-        required=False,
+    custom_fields = CustomFieldsDataField(
+        source='custom_field_data',
         default=CreateOnlyDefault(CustomFieldDefaultValues())
         default=CreateOnlyDefault(CustomFieldDefaultValues())
     )
     )
 
 
@@ -148,70 +135,13 @@ class CustomFieldModelSerializer(ValidatedModelSerializer):
             fields = CustomField.objects.filter(obj_type=content_type)
             fields = CustomField.objects.filter(obj_type=content_type)
 
 
             # Populate CustomFieldValues for each instance from database
             # Populate CustomFieldValues for each instance from database
-            try:
+            if type(self.instance) in (list, tuple):
                 for obj in self.instance:
                 for obj in self.instance:
                     self._populate_custom_fields(obj, fields)
                     self._populate_custom_fields(obj, fields)
-            except TypeError:
+            else:
                 self._populate_custom_fields(self.instance, fields)
                 self._populate_custom_fields(self.instance, fields)
 
 
     def _populate_custom_fields(self, instance, custom_fields):
     def _populate_custom_fields(self, instance, custom_fields):
         instance.custom_fields = {}
         instance.custom_fields = {}
         for field in custom_fields:
         for field in custom_fields:
-            value = instance.cf.get(field.name)
-            if field.type == CustomFieldTypeChoices.TYPE_SELECT and value:
-                instance.custom_fields[field.name] = CustomFieldChoiceSerializer(value).data
-            else:
-                instance.custom_fields[field.name] = value
-
-    def _save_custom_fields(self, instance, custom_fields):
-        content_type = ContentType.objects.get_for_model(self.Meta.model)
-        for field_name, value in custom_fields.items():
-            custom_field = CustomField.objects.get(name=field_name)
-            CustomFieldValue.objects.update_or_create(
-                field=custom_field,
-                obj_type=content_type,
-                obj_id=instance.pk,
-                defaults={'serialized_value': custom_field.serialize_value(value)},
-            )
-
-    def create(self, validated_data):
-
-        with transaction.atomic():
-
-            instance = super().create(validated_data)
-
-            # Save custom fields
-            custom_fields = validated_data.get('custom_fields')
-            if custom_fields is not None:
-                self._save_custom_fields(instance, custom_fields)
-                instance.custom_fields = custom_fields
-
-        return instance
-
-    def update(self, instance, validated_data):
-
-        with transaction.atomic():
-
-            custom_fields = validated_data.get('custom_fields')
-            instance._cf = custom_fields
-
-            instance = super().update(instance, validated_data)
-
-            # Save custom fields
-            if custom_fields is not None:
-                self._save_custom_fields(instance, custom_fields)
-                instance.custom_fields = custom_fields
-
-        return instance
-
-
-class CustomFieldChoiceSerializer(serializers.ModelSerializer):
-    """
-    Imitate utilities.api.ChoiceFieldSerializer
-    """
-    value = serializers.IntegerField(source='pk')
-    label = serializers.CharField(source='value')
-
-    class Meta:
-        model = CustomFieldChoice
-        fields = ['value', 'label']
+            instance.custom_fields[field.name] = instance.cf.get(field.name)

+ 0 - 3
netbox/extras/api/urls.py

@@ -5,9 +5,6 @@ from . import views
 router = OrderedDefaultRouter()
 router = OrderedDefaultRouter()
 router.APIRootView = views.ExtrasRootView
 router.APIRootView = views.ExtrasRootView
 
 
-# Custom field choices
-router.register('_custom_field_choices', views.CustomFieldChoicesViewSet, basename='custom-field-choice')
-
 # Export templates
 # Export templates
 router.register('export-templates', views.ExportTemplateViewSet)
 router.register('export-templates', views.ExportTemplateViewSet)
 
 

+ 2 - 46
netbox/extras/api/views.py

@@ -14,9 +14,7 @@ from rq import Worker
 
 
 from extras import filters
 from extras import filters
 from extras.choices import JobResultStatusChoices
 from extras.choices import JobResultStatusChoices
-from extras.models import (
-    ConfigContext, CustomFieldChoice, ExportTemplate, ImageAttachment, ObjectChange, JobResult, Tag,
-)
+from extras.models import ConfigContext, ExportTemplate, ImageAttachment, ObjectChange, JobResult, Tag
 from extras.reports import get_report, get_reports, run_report
 from extras.reports import get_report, get_reports, run_report
 from extras.scripts import get_script, get_scripts, run_script
 from extras.scripts import get_script, get_scripts, run_script
 from utilities.api import IsAuthenticatedOrLoginNotRequired, ModelViewSet
 from utilities.api import IsAuthenticatedOrLoginNotRequired, ModelViewSet
@@ -34,36 +32,6 @@ class ExtrasRootView(APIRootView):
         return 'Extras'
         return 'Extras'
 
 
 
 
-#
-# Custom field choices
-#
-
-class CustomFieldChoicesViewSet(ViewSet):
-    """
-    """
-    permission_classes = [IsAuthenticatedOrLoginNotRequired]
-
-    def __init__(self, *args, **kwargs):
-        super(CustomFieldChoicesViewSet, self).__init__(*args, **kwargs)
-
-        self._fields = OrderedDict()
-
-        for cfc in CustomFieldChoice.objects.all():
-            self._fields.setdefault(cfc.field.name, {})
-            self._fields[cfc.field.name][cfc.value] = cfc.pk
-
-    def list(self, request):
-        return Response(self._fields)
-
-    def retrieve(self, request, pk):
-        if pk not in self._fields:
-            raise Http404
-        return Response(self._fields[pk])
-
-    def get_view_name(self):
-        return "Custom Field choices"
-
-
 #
 #
 # Custom fields
 # Custom fields
 #
 #
@@ -77,26 +45,14 @@ class CustomFieldModelViewSet(ModelViewSet):
 
 
         # Gather all custom fields for the model
         # Gather all custom fields for the model
         content_type = ContentType.objects.get_for_model(self.queryset.model)
         content_type = ContentType.objects.get_for_model(self.queryset.model)
-        custom_fields = content_type.custom_fields.prefetch_related('choices')
-
-        # Cache all relevant CustomFieldChoices. This saves us from having to do a lookup per select field per object.
-        custom_field_choices = {}
-        for field in custom_fields:
-            for cfc in field.choices.all():
-                custom_field_choices[cfc.id] = cfc.value
-        custom_field_choices = custom_field_choices
+        custom_fields = content_type.custom_fields.all()
 
 
         context = super().get_serializer_context()
         context = super().get_serializer_context()
         context.update({
         context.update({
             'custom_fields': custom_fields,
             'custom_fields': custom_fields,
-            'custom_field_choices': custom_field_choices,
         })
         })
         return context
         return context
 
 
-    def get_queryset(self):
-        # Prefetch custom field values
-        return super().get_queryset().prefetch_related('custom_field_values__field')
-
 
 
 #
 #
 # Export templates
 # Export templates

+ 15 - 32
netbox/extras/filters.py

@@ -22,15 +22,20 @@ __all__ = (
     'TagFilterSet',
     'TagFilterSet',
 )
 )
 
 
+EXACT_FILTER_TYPES = (
+    CustomFieldTypeChoices.TYPE_BOOLEAN,
+    CustomFieldTypeChoices.TYPE_DATE,
+    CustomFieldTypeChoices.TYPE_INTEGER,
+    CustomFieldTypeChoices.TYPE_SELECT,
+)
+
 
 
 class CustomFieldFilter(django_filters.Filter):
 class CustomFieldFilter(django_filters.Filter):
     """
     """
     Filter objects by the presence of a CustomFieldValue. The filter's name is used as the CustomField name.
     Filter objects by the presence of a CustomFieldValue. The filter's name is used as the CustomField name.
     """
     """
-
     def __init__(self, custom_field, *args, **kwargs):
     def __init__(self, custom_field, *args, **kwargs):
-        self.cf_type = custom_field.type
-        self.filter_logic = custom_field.filter_logic
+        self.custom_field = custom_field
         super().__init__(*args, **kwargs)
         super().__init__(*args, **kwargs)
 
 
     def filter(self, queryset, value):
     def filter(self, queryset, value):
@@ -39,44 +44,22 @@ class CustomFieldFilter(django_filters.Filter):
         if value is None or not value.strip():
         if value is None or not value.strip():
             return queryset
             return queryset
 
 
-        # Selection fields get special treatment (values must be integers)
-        if self.cf_type == CustomFieldTypeChoices.TYPE_SELECT:
-            try:
-                # Treat 0 as None
-                if int(value) == 0:
-                    return queryset.exclude(
-                        custom_field_values__field__name=self.field_name,
-                    )
-                # Match on exact CustomFieldChoice PK
-                else:
-                    return queryset.filter(
-                        custom_field_values__field__name=self.field_name,
-                        custom_field_values__serialized_value=value,
-                    )
-            except ValueError:
-                return queryset.none()
-
         # Apply the assigned filter logic (exact or loose)
         # Apply the assigned filter logic (exact or loose)
-        if (self.cf_type == CustomFieldTypeChoices.TYPE_BOOLEAN or
-                self.filter_logic == CustomFieldFilterLogicChoices.FILTER_EXACT):
-            queryset = queryset.filter(
-                custom_field_values__field__name=self.field_name,
-                custom_field_values__serialized_value=value
-            )
+        if (
+            self.custom_field.type in EXACT_FILTER_TYPES or
+            self.custom_field.filter_logic == CustomFieldFilterLogicChoices.FILTER_EXACT
+        ):
+            kwargs = {f'custom_field_data__{self.field_name}': value}
         else:
         else:
-            queryset = queryset.filter(
-                custom_field_values__field__name=self.field_name,
-                custom_field_values__serialized_value__icontains=value
-            )
+            kwargs = {f'custom_field_data__{self.field_name}__icontains': value}
 
 
-        return queryset
+        return queryset.filter(**kwargs)
 
 
 
 
 class CustomFieldFilterSet(django_filters.FilterSet):
 class CustomFieldFilterSet(django_filters.FilterSet):
     """
     """
     Dynamically add a Filter for each CustomField applicable to the parent model.
     Dynamically add a Filter for each CustomField applicable to the parent model.
     """
     """
-
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
         super().__init__(*args, **kwargs)
 
 

+ 5 - 49
netbox/extras/forms.py

@@ -12,7 +12,7 @@ from utilities.forms import (
 )
 )
 from virtualization.models import Cluster, ClusterGroup
 from virtualization.models import Cluster, ClusterGroup
 from .choices import *
 from .choices import *
-from .models import ConfigContext, CustomField, CustomFieldValue, ImageAttachment, ObjectChange, Tag
+from .models import ConfigContext, CustomField, ImageAttachment, ObjectChange, Tag
 
 
 
 
 #
 #
@@ -25,78 +25,34 @@ class CustomFieldModelForm(forms.ModelForm):
 
 
         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_fields = []
-        self.custom_field_values = {}
 
 
         super().__init__(*args, **kwargs)
         super().__init__(*args, **kwargs)
 
 
-        if self.instance._cf is None:
-            self.instance._cf = {}
-
         self._append_customfield_fields()
         self._append_customfield_fields()
 
 
     def _append_customfield_fields(self):
     def _append_customfield_fields(self):
         """
         """
         Append form fields for all CustomFields assigned to this model.
         Append form fields for all CustomFields assigned to this model.
         """
         """
-        # Retrieve initial CustomField values for the instance
-        if self.instance.pk:
-            for cfv in CustomFieldValue.objects.filter(
-                obj_type=self.obj_type,
-                obj_id=self.instance.pk
-            ).prefetch_related('field'):
-                self.custom_field_values[cfv.field.name] = cfv.serialized_value
-
         # Append form fields; assign initial values if modifying and existing object
         # Append form fields; assign initial values if modifying and existing object
         for cf in CustomField.objects.filter(obj_type=self.obj_type):
         for cf in CustomField.objects.filter(obj_type=self.obj_type):
             field_name = 'cf_{}'.format(cf.name)
             field_name = 'cf_{}'.format(cf.name)
             if self.instance.pk:
             if self.instance.pk:
                 self.fields[field_name] = cf.to_form_field(set_initial=False)
                 self.fields[field_name] = cf.to_form_field(set_initial=False)
-                value = self.custom_field_values.get(cf.name)
-                self.fields[field_name].initial = value
-                self.instance._cf[cf.name] = value
+                self.fields[field_name].initial = self.instance.custom_field_data.get(cf.name)
             else:
             else:
                 self.fields[field_name] = cf.to_form_field()
                 self.fields[field_name] = cf.to_form_field()
-                self.instance._cf[cf.name] = self.fields[field_name].initial
 
 
             # Annotate the field in the list of CustomField form fields
             # Annotate the field in the list of CustomField form fields
             self.custom_fields.append(field_name)
             self.custom_fields.append(field_name)
 
 
-    def _save_custom_fields(self):
-
-        for field_name in self.custom_fields:
-            try:
-                cfv = CustomFieldValue.objects.prefetch_related('field').get(
-                    field=self.fields[field_name].model,
-                    obj_type=self.obj_type,
-                    obj_id=self.instance.pk
-                )
-            except CustomFieldValue.DoesNotExist:
-                # Skip this field if none exists already and its value is empty
-                if self.cleaned_data[field_name] in [None, '']:
-                    continue
-                cfv = CustomFieldValue(
-                    field=self.fields[field_name].model,
-                    obj_type=self.obj_type,
-                    obj_id=self.instance.pk
-                )
-            cfv.value = self.cleaned_data[field_name]
-            cfv.save()
-
     def save(self, commit=True):
     def save(self, commit=True):
 
 
-        # Cache custom field values on object prior to save to ensure change logging
+        # Save custom field data on instance
         for cf_name in self.custom_fields:
         for cf_name in self.custom_fields:
-            self.instance._cf[cf_name[3:]] = self.cleaned_data.get(cf_name)
-
-        obj = super().save(commit)
-
-        # Handle custom fields the same way we do M2M fields
-        if commit:
-            self._save_custom_fields()
-        else:
-            obj.save_custom_fields = self._save_custom_fields
+            self.instance.custom_field_data[cf_name[3:]] = self.cleaned_data.get(cf_name)
 
 
-        return obj
+        return super().save(commit)
 
 
 
 
 class CustomFieldModelCSVForm(CSVModelForm, CustomFieldModelForm):
 class CustomFieldModelCSVForm(CSVModelForm, CustomFieldModelForm):

+ 35 - 0
netbox/extras/migrations/0050_customfield_add_choices.py

@@ -0,0 +1,35 @@
+import django.contrib.postgres.fields
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('extras', '0049_remove_graph'),
+    ]
+
+    operations = [
+        # Rename reverse relation on CustomFieldChoice
+        migrations.AlterField(
+            model_name='customfieldchoice',
+            name='field',
+            field=models.ForeignKey(
+                limit_choices_to={'type': 'select'},
+                on_delete=django.db.models.deletion.CASCADE,
+                related_name='_choices',
+                to='extras.customfield'
+            ),
+        ),
+        # Add choices field to CustomField
+        migrations.AddField(
+            model_name='customfield',
+            name='choices',
+            field=django.contrib.postgres.fields.ArrayField(
+                base_field=models.CharField(max_length=100),
+                blank=True,
+                null=True,
+                size=None
+            ),
+        ),
+    ]

+ 73 - 0
netbox/extras/migrations/0051_migrate_customfields.py

@@ -0,0 +1,73 @@
+from django.db import migrations
+
+from extras.choices import CustomFieldTypeChoices
+
+
+def deserialize_value(field, value):
+    """
+    Convert serialized values to JSON equivalents.
+    """
+    if field.type in (CustomFieldTypeChoices.TYPE_INTEGER):
+        return int(value)
+    if field.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
+        return bool(int(value))
+    if field.type == CustomFieldTypeChoices.TYPE_SELECT:
+        return field._choices.get(pk=int(value)).value
+    return value
+
+
+def migrate_customfieldchoices(apps, schema_editor):
+    """
+    Collect all CustomFieldChoices for each applicable CustomField, and save them locally as an array on
+    the CustomField instance.
+    """
+    CustomField = apps.get_model('extras', 'CustomField')
+    CustomFieldChoice = apps.get_model('extras', 'CustomFieldChoice')
+
+    for cf in CustomField.objects.filter(type='select'):
+        cf.choices = [
+            cfc.value for cfc in CustomFieldChoice.objects.filter(field=cf).order_by('weight', 'value')
+        ]
+        cf.save()
+
+
+def migrate_customfieldvalues(apps, schema_editor):
+    """
+    Copy data from CustomFieldValues into the custom_field_data JSON field on each model instance.
+    """
+    CustomFieldValue = apps.get_model('extras', 'CustomFieldValue')
+
+    for cfv in CustomFieldValue.objects.prefetch_related('field').exclude(serialized_value=''):
+        model = apps.get_model(cfv.obj_type.app_label, cfv.obj_type.model)
+
+        # Read and update custom field value for each instance
+        # TODO: This can be done more efficiently once .update() is supported for JSON fields
+        cf_data = model.objects.filter(pk=cfv.obj_id).values('custom_field_data').first()
+        try:
+            cf_data['custom_field_data'][cfv.field.name] = deserialize_value(cfv.field, cfv.serialized_value)
+        except ValueError as e:
+            print(f'{cfv.field.name} ({cfv.field.type}): {cfv.serialized_value} ({cfv.pk})')
+            raise e
+        model.objects.filter(pk=cfv.obj_id).update(**cf_data)
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('circuits', '0020_custom_field_data'),
+        ('dcim', '0116_custom_field_data'),
+        ('extras', '0050_customfield_add_choices'),
+        ('ipam', '0038_custom_field_data'),
+        ('secrets', '0010_custom_field_data'),
+        ('tenancy', '0010_custom_field_data'),
+        ('virtualization', '0018_custom_field_data'),
+    ]
+
+    operations = [
+        migrations.RunPython(
+            code=migrate_customfieldchoices
+        ),
+        migrations.RunPython(
+            code=migrate_customfieldvalues
+        ),
+    ]

+ 17 - 0
netbox/extras/migrations/0052_delete_customfieldchoice_customfieldvalue.py

@@ -0,0 +1,17 @@
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('extras', '0051_migrate_customfields'),
+    ]
+
+    operations = [
+        migrations.DeleteModel(
+            name='CustomFieldChoice',
+        ),
+        migrations.DeleteModel(
+            name='CustomFieldValue',
+        ),
+    ]

+ 1 - 3
netbox/extras/models/__init__.py

@@ -1,5 +1,5 @@
 from .change_logging import ChangeLoggedModel, ObjectChange
 from .change_logging import ChangeLoggedModel, ObjectChange
-from .customfields import CustomField, CustomFieldChoice, CustomFieldModel, CustomFieldValue
+from .customfields import CustomField, CustomFieldModel
 from .models import (
 from .models import (
     ConfigContext, ConfigContextModel, CustomLink, ExportTemplate, ImageAttachment, JobResult, Report, Script,
     ConfigContext, ConfigContextModel, CustomLink, ExportTemplate, ImageAttachment, JobResult, Report, Script,
     Webhook,
     Webhook,
@@ -11,9 +11,7 @@ __all__ = (
     'ConfigContext',
     'ConfigContext',
     'ConfigContextModel',
     'ConfigContextModel',
     'CustomField',
     'CustomField',
-    'CustomFieldChoice',
     'CustomFieldModel',
     'CustomFieldModel',
-    'CustomFieldValue',
     'CustomLink',
     'CustomLink',
     'ExportTemplate',
     'ExportTemplate',
     'ImageAttachment',
     'ImageAttachment',

+ 50 - 156
netbox/extras/models/customfields.py

@@ -1,9 +1,9 @@
 from collections import OrderedDict
 from collections import OrderedDict
-from datetime import date
 
 
 from django import forms
 from django import forms
-from django.contrib.contenttypes.fields import GenericForeignKey
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
+from django.contrib.postgres.fields import ArrayField
+from django.core.serializers.json import DjangoJSONEncoder
 from django.core.validators import ValidationError
 from django.core.validators import ValidationError
 from django.db import models
 from django.db import models
 
 
@@ -12,49 +12,34 @@ from extras.choices import *
 from extras.utils import FeatureQuery
 from extras.utils import FeatureQuery
 
 
 
 
-#
-# Custom fields
-#
-
 class CustomFieldModel(models.Model):
 class CustomFieldModel(models.Model):
+    """
+    Abstract class for any model which may have custom fields associated with it.
+    """
+    custom_field_data = models.JSONField(
+        encoder=DjangoJSONEncoder,
+        blank=True,
+        default=dict
+    )
 
 
     class Meta:
     class Meta:
         abstract = True
         abstract = True
 
 
-    def __init__(self, *args, custom_fields=None, **kwargs):
-        self._cf = custom_fields
-        super().__init__(*args, **kwargs)
-
-    def cache_custom_fields(self):
-        """
-        Cache all custom field values for this instance
-        """
-        self._cf = {
-            field.name: value for field, value in self.get_custom_fields().items()
-        }
-
     @property
     @property
     def cf(self):
     def cf(self):
         """
         """
-        Name-based CustomFieldValue accessor for use in templates
+        Convenience wrapper for custom field data.
         """
         """
-        if self._cf is None:
-            self.cache_custom_fields()
-        return self._cf
+        return self.custom_field_data
 
 
     def get_custom_fields(self):
     def get_custom_fields(self):
         """
         """
         Return a dictionary of custom fields for a single object in the form {<field>: value}.
         Return a dictionary of custom fields for a single object in the form {<field>: value}.
         """
         """
         fields = CustomField.objects.get_for_model(self)
         fields = CustomField.objects.get_for_model(self)
-
-        # If the object exists, populate its custom fields with values
-        if hasattr(self, 'pk'):
-            values = self.custom_field_values.all()
-            values_dict = {cfv.field_id: cfv.value for cfv in values}
-            return OrderedDict([(field, values_dict.get(field.pk)) for field in fields])
-        else:
-            return OrderedDict([(field, None) for field in fields])
+        return OrderedDict([
+            (field, self.custom_field_data.get(field.name)) for field in fields
+        ])
 
 
 
 
 class CustomFieldManager(models.Manager):
 class CustomFieldManager(models.Manager):
@@ -116,6 +101,12 @@ class CustomField(models.Model):
         default=100,
         default=100,
         help_text='Fields with higher weights appear lower in a form.'
         help_text='Fields with higher weights appear lower in a form.'
     )
     )
+    choices = ArrayField(
+        base_field=models.CharField(max_length=100),
+        blank=True,
+        null=True,
+        help_text='Comma-separated list of available choices (for selection fields)'
+    )
 
 
     objects = CustomFieldManager()
     objects = CustomFieldManager()
 
 
@@ -125,41 +116,29 @@ class CustomField(models.Model):
     def __str__(self):
     def __str__(self):
         return self.label or self.name.replace('_', ' ').capitalize()
         return self.label or self.name.replace('_', ' ').capitalize()
 
 
-    def serialize_value(self, value):
+    def remove_stale_data(self, content_types):
         """
         """
-        Serialize the given value to a string suitable for storage as a CustomFieldValue
+        Delete custom field data which is no longer relevant (either because the CustomField is
+        no longer assigned to a model, or because it has been deleted).
         """
         """
-        if value is None:
-            return ''
-        if self.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
-            return str(int(bool(value)))
-        if self.type == CustomFieldTypeChoices.TYPE_DATE:
-            # Could be date/datetime object or string
-            try:
-                return value.strftime('%Y-%m-%d')
-            except AttributeError:
-                return value
-        if self.type == CustomFieldTypeChoices.TYPE_SELECT:
-            # Could be ModelChoiceField or TypedChoiceField
-            return str(value.id) if hasattr(value, 'id') else str(value)
-        return value
+        for ct in content_types:
+            model = ct.model_class()
+            for obj in model.objects.filter(**{f'custom_field_data__{self.name}__isnull': False}):
+                del(obj.custom_field_data[self.name])
+                obj.save()
 
 
-    def deserialize_value(self, serialized_value):
-        """
-        Convert a string into the object it represents depending on the type of field
-        """
-        if serialized_value == '':
-            return None
-        if self.type == CustomFieldTypeChoices.TYPE_INTEGER:
-            return int(serialized_value)
-        if self.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
-            return bool(int(serialized_value))
-        if self.type == CustomFieldTypeChoices.TYPE_DATE:
-            # Read date as YYYY-MM-DD
-            return date(*[int(n) for n in serialized_value.split('-')])
-        if self.type == CustomFieldTypeChoices.TYPE_SELECT:
-            return self.choices.get(pk=int(serialized_value))
-        return serialized_value
+    def clean(self):
+        # Choices can be set only on selection fields
+        if self.choices and self.type != CustomFieldTypeChoices.TYPE_SELECT:
+            raise ValidationError({
+                'choices': "Choices may be set only for selection-type custom fields."
+            })
+
+        # A selection field's default (if any) must be present in its available choices
+        if self.type == CustomFieldTypeChoices.TYPE_SELECT and self.default and self.default not in self.choices:
+            raise ValidationError({
+                'default': f"The specified default value ({self.default}) is not listed as an available choice."
+            })
 
 
     def to_form_field(self, set_initial=True, enforce_required=True, for_csv_import=False):
     def to_form_field(self, set_initial=True, enforce_required=True, for_csv_import=False):
         """
         """
@@ -180,15 +159,11 @@ class CustomField(models.Model):
         elif self.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
         elif self.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
             choices = (
             choices = (
                 (None, '---------'),
                 (None, '---------'),
-                (1, 'True'),
-                (0, 'False'),
+                (True, 'True'),
+                (False, '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
+            if initial is not None:
+                initial = bool(initial)
             field = forms.NullBooleanField(
             field = forms.NullBooleanField(
                 required=required, initial=initial, widget=StaticSelect2(choices=choices)
                 required=required, initial=initial, widget=StaticSelect2(choices=choices)
             )
             )
@@ -199,16 +174,14 @@ class CustomField(models.Model):
 
 
         # Select
         # Select
         elif self.type == CustomFieldTypeChoices.TYPE_SELECT:
         elif self.type == CustomFieldTypeChoices.TYPE_SELECT:
-            choices = [(cfc.pk, cfc.value) for cfc in self.choices.all()]
+            choices = [(c, c) for c in self.choices]
 
 
             if not required:
             if not required:
                 choices = add_blank_choice(choices)
                 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
+            # Set the initial value to the first available choice (if any)
+            if set_initial and self.choices:
+                initial = self.choices[0]
 
 
             field_class = CSVChoiceField if for_csv_import else forms.ChoiceField
             field_class = CSVChoiceField if for_csv_import else forms.ChoiceField
             field = field_class(
             field = field_class(
@@ -224,87 +197,8 @@ class CustomField(models.Model):
             field = forms.CharField(max_length=255, required=required, initial=initial)
             field = forms.CharField(max_length=255, required=required, initial=initial)
 
 
         field.model = self
         field.model = self
-        field.label = self.label if self.label else self.name.replace('_', ' ').capitalize()
+        field.label = str(self)
         if self.description:
         if self.description:
             field.help_text = self.description
             field.help_text = self.description
 
 
         return field
         return field
-
-
-class CustomFieldValue(models.Model):
-    field = models.ForeignKey(
-        to='extras.CustomField',
-        on_delete=models.CASCADE,
-        related_name='values'
-    )
-    obj_type = models.ForeignKey(
-        to=ContentType,
-        on_delete=models.PROTECT,
-        related_name='+'
-    )
-    obj_id = models.PositiveIntegerField()
-    obj = GenericForeignKey(
-        ct_field='obj_type',
-        fk_field='obj_id'
-    )
-    serialized_value = models.CharField(
-        max_length=255
-    )
-
-    class Meta:
-        ordering = ('obj_type', 'obj_id', 'pk')  # (obj_type, obj_id) may be non-unique
-        unique_together = ('field', 'obj_type', 'obj_id')
-
-    def __str__(self):
-        return '{} {}'.format(self.obj, self.field)
-
-    @property
-    def value(self):
-        return self.field.deserialize_value(self.serialized_value)
-
-    @value.setter
-    def value(self, value):
-        self.serialized_value = self.field.serialize_value(value)
-
-    def save(self, *args, **kwargs):
-        # Delete this object if it no longer has a value to store
-        if self.pk and self.value is None:
-            self.delete()
-        else:
-            super().save(*args, **kwargs)
-
-
-class CustomFieldChoice(models.Model):
-    field = models.ForeignKey(
-        to='extras.CustomField',
-        on_delete=models.CASCADE,
-        related_name='choices',
-        limit_choices_to={'type': CustomFieldTypeChoices.TYPE_SELECT}
-    )
-    value = models.CharField(
-        max_length=100
-    )
-    weight = models.PositiveSmallIntegerField(
-        default=100,
-        help_text='Higher weights appear lower in the list'
-    )
-
-    class Meta:
-        ordering = ['field', 'weight', 'value']
-        unique_together = ['field', 'value']
-
-    def __str__(self):
-        return self.value
-
-    def clean(self):
-        if self.field.type != CustomFieldTypeChoices.TYPE_SELECT:
-            raise ValidationError("Custom field choices can only be assigned to selection fields.")
-
-    def delete(self, using=None, keep_parents=False):
-        # When deleting a CustomFieldChoice, delete all CustomFieldValues which point to it
-        pk = self.pk
-        super().delete(using, keep_parents)
-        CustomFieldValue.objects.filter(
-            field__type=CustomFieldTypeChoices.TYPE_SELECT,
-            serialized_value=str(pk)
-        ).delete()

+ 1 - 19
netbox/extras/querysets.py

@@ -1,26 +1,8 @@
-from collections import OrderedDict
-
-from django.db.models import Q, QuerySet
+from django.db.models import Q
 
 
 from utilities.querysets import RestrictedQuerySet
 from utilities.querysets import RestrictedQuerySet
 
 
 
 
-class CustomFieldQueryset:
-    """
-    Annotate custom fields on objects within a QuerySet.
-    """
-    def __init__(self, queryset, custom_fields):
-        self.queryset = queryset
-        self.model = queryset.model
-        self.custom_fields = custom_fields
-
-    def __iter__(self):
-        for obj in self.queryset:
-            values_dict = {cfv.field_id: cfv.value for cfv in obj.custom_field_values.all()}
-            obj.custom_fields = OrderedDict([(field, values_dict.get(field.pk)) for field in self.custom_fields])
-            yield obj
-
-
 class ConfigContextQuerySet(RestrictedQuerySet):
 class ConfigContextQuerySet(RestrictedQuerySet):
 
 
     def get_for_object(self, obj):
     def get_for_object(self, obj):

+ 26 - 1
netbox/extras/signals.py

@@ -3,12 +3,14 @@ from datetime import timedelta
 
 
 from cacheops.signals import cache_invalidated, cache_read
 from cacheops.signals import cache_invalidated, cache_read
 from django.conf import settings
 from django.conf import settings
+from django.contrib.contenttypes.models import ContentType
+from django.db.models.signals import m2m_changed, pre_delete
 from django.utils import timezone
 from django.utils import timezone
 from django_prometheus.models import model_deletes, model_inserts, model_updates
 from django_prometheus.models import model_deletes, model_inserts, model_updates
 from prometheus_client import Counter
 from prometheus_client import Counter
 
 
 from .choices import ObjectChangeActionChoices
 from .choices import ObjectChangeActionChoices
-from .models import ObjectChange
+from .models import CustomField, ObjectChange
 from .webhooks import enqueue_webhooks
 from .webhooks import enqueue_webhooks
 
 
 
 
@@ -71,6 +73,29 @@ def _handle_deleted_object(request, sender, instance, **kwargs):
     model_deletes.labels(instance._meta.model_name).inc()
     model_deletes.labels(instance._meta.model_name).inc()
 
 
 
 
+#
+# Custom fields
+#
+
+def handle_cf_removed_obj_types(instance, action, pk_set, **kwargs):
+    """
+    Handle the cleanup of old custom field data when a CustomField is removed from one or more ContentTypes.
+    """
+    if action == 'post_remove':
+        instance.remove_stale_data(ContentType.objects.filter(pk__in=pk_set))
+
+
+def handle_cf_deleted(instance, **kwargs):
+    """
+    Handle the cleanup of old custom field data when a CustomField is deleted.
+    """
+    instance.remove_stale_data(instance.obj_type.all())
+
+
+m2m_changed.connect(handle_cf_removed_obj_types, sender=CustomField.obj_type.through)
+pre_delete.connect(handle_cf_deleted, sender=CustomField)
+
+
 #
 #
 # Caching
 # Caching
 #
 #

+ 9 - 13
netbox/extras/tests/test_changelog.py

@@ -5,7 +5,7 @@ from rest_framework import status
 from dcim.choices import SiteStatusChoices
 from dcim.choices import SiteStatusChoices
 from dcim.models import Site
 from dcim.models import Site
 from extras.choices import *
 from extras.choices import *
-from extras.models import CustomField, CustomFieldValue, ObjectChange, Tag
+from extras.models import CustomField, ObjectChange, Tag
 from utilities.testing import APITestCase
 from utilities.testing import APITestCase
 from utilities.testing.utils import post_data
 from utilities.testing.utils import post_data
 from utilities.testing.views import ModelViewTestCase
 from utilities.testing.views import ModelViewTestCase
@@ -93,16 +93,14 @@ class ChangeLogViewTest(ModelViewTestCase):
     def test_delete_object(self):
     def test_delete_object(self):
         site = Site(
         site = Site(
             name='Test Site 1',
             name='Test Site 1',
-            slug='test-site-1'
+            slug='test-site-1',
+            custom_field_data={
+                'my_field': 'ABC'
+            }
         )
         )
         site.save()
         site.save()
         self.create_tags('Tag 1', 'Tag 2')
         self.create_tags('Tag 1', 'Tag 2')
         site.tags.set('Tag 1', 'Tag 2')
         site.tags.set('Tag 1', 'Tag 2')
-        CustomFieldValue.objects.create(
-            field=CustomField.objects.get(name='my_field'),
-            obj=site,
-            value='ABC'
-        )
 
 
         request = {
         request = {
             'path': self._get_url('delete', instance=site),
             'path': self._get_url('delete', instance=site),
@@ -209,15 +207,13 @@ class ChangeLogAPITest(APITestCase):
     def test_delete_object(self):
     def test_delete_object(self):
         site = Site(
         site = Site(
             name='Test Site 1',
             name='Test Site 1',
-            slug='test-site-1'
+            slug='test-site-1',
+            custom_field_data={
+                'my_field': 'ABC'
+            }
         )
         )
         site.save()
         site.save()
         site.tags.set(*Tag.objects.all()[:2])
         site.tags.set(*Tag.objects.all()[:2])
-        CustomFieldValue.objects.create(
-            field=CustomField.objects.get(name='my_field'),
-            obj=site,
-            value='ABC'
-        )
         self.assertEqual(ObjectChange.objects.count(), 0)
         self.assertEqual(ObjectChange.objects.count(), 0)
         self.add_permissions('dcim.delete_site')
         self.add_permissions('dcim.delete_site')
         url = reverse('dcim-api:site-detail', kwargs={'pk': site.pk})
         url = reverse('dcim-api:site-detail', kwargs={'pk': site.pk})

+ 101 - 177
netbox/extras/tests/test_customfields.py

@@ -1,5 +1,3 @@
-from datetime import date
-
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.urls import reverse
 from django.urls import reverse
 from rest_framework import status
 from rest_framework import status
@@ -7,7 +5,7 @@ from rest_framework import status
 from dcim.forms import SiteCSVForm
 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
 from utilities.testing import APITestCase, TestCase
 from utilities.testing import APITestCase, TestCase
 from virtualization.models import VirtualMachine
 from virtualization.models import VirtualMachine
 
 
@@ -30,7 +28,7 @@ class CustomFieldTest(TestCase):
             {'field_type': CustomFieldTypeChoices.TYPE_INTEGER, 'field_value': 42, 'empty_value': None},
             {'field_type': CustomFieldTypeChoices.TYPE_INTEGER, 'field_value': 42, 'empty_value': None},
             {'field_type': CustomFieldTypeChoices.TYPE_BOOLEAN, 'field_value': True, 'empty_value': None},
             {'field_type': CustomFieldTypeChoices.TYPE_BOOLEAN, 'field_value': True, 'empty_value': None},
             {'field_type': CustomFieldTypeChoices.TYPE_BOOLEAN, 'field_value': False, 'empty_value': None},
             {'field_type': CustomFieldTypeChoices.TYPE_BOOLEAN, 'field_value': False, 'empty_value': None},
-            {'field_type': CustomFieldTypeChoices.TYPE_DATE, 'field_value': date(2016, 6, 23), 'empty_value': None},
+            {'field_type': CustomFieldTypeChoices.TYPE_DATE, 'field_value': '2016-06-23', 'empty_value': None},
             {'field_type': CustomFieldTypeChoices.TYPE_URL, 'field_value': 'http://example.com/', 'empty_value': ''},
             {'field_type': CustomFieldTypeChoices.TYPE_URL, 'field_value': 'http://example.com/', 'empty_value': ''},
         )
         )
 
 
@@ -46,18 +44,18 @@ class CustomFieldTest(TestCase):
 
 
             # Assign a value to the first Site
             # Assign a value to the first Site
             site = Site.objects.first()
             site = Site.objects.first()
-            cfv = CustomFieldValue(field=cf, obj_type=obj_type, obj_id=site.id)
-            cfv.value = data['field_value']
-            cfv.save()
+            site.custom_field_data[cf.name] = data['field_value']
+            site.save()
 
 
             # Retrieve the stored value
             # Retrieve the stored value
-            cfv = CustomFieldValue.objects.filter(obj_type=obj_type, obj_id=site.pk).first()
-            self.assertEqual(cfv.value, data['field_value'])
+            site.refresh_from_db()
+            self.assertEqual(site.custom_field_data[cf.name], data['field_value'])
 
 
             # Delete the stored value
             # Delete the stored value
-            cfv.value = data['empty_value']
-            cfv.save()
-            self.assertEqual(CustomFieldValue.objects.filter(obj_type=obj_type, obj_id=site.pk).count(), 0)
+            site.custom_field_data.pop(cf.name)
+            site.save()
+            site.refresh_from_db()
+            self.assertIsNone(site.custom_field_data.get(cf.name))
 
 
             # Delete the custom field
             # Delete the custom field
             cf.delete()
             cf.delete()
@@ -67,32 +65,30 @@ class CustomFieldTest(TestCase):
         obj_type = ContentType.objects.get_for_model(Site)
         obj_type = ContentType.objects.get_for_model(Site)
 
 
         # Create a custom field
         # Create a custom field
-        cf = CustomField(type=CustomFieldTypeChoices.TYPE_SELECT, name='my_field', required=False)
+        cf = CustomField(
+            type=CustomFieldTypeChoices.TYPE_SELECT,
+            name='my_field',
+            required=False,
+            choices=['Option A', 'Option B', 'Option C']
+        )
         cf.save()
         cf.save()
         cf.obj_type.set([obj_type])
         cf.obj_type.set([obj_type])
         cf.save()
         cf.save()
 
 
-        # Create some choices for the field
-        CustomFieldChoice.objects.bulk_create([
-            CustomFieldChoice(field=cf, value='Option A'),
-            CustomFieldChoice(field=cf, value='Option B'),
-            CustomFieldChoice(field=cf, value='Option C'),
-        ])
-
         # Assign a value to the first Site
         # Assign a value to the first Site
         site = Site.objects.first()
         site = Site.objects.first()
-        cfv = CustomFieldValue(field=cf, obj_type=obj_type, obj_id=site.id)
-        cfv.value = cf.choices.first()
-        cfv.save()
+        site.custom_field_data[cf.name] = 'Option A'
+        site.save()
 
 
         # Retrieve the stored value
         # Retrieve the stored value
-        cfv = CustomFieldValue.objects.filter(obj_type=obj_type, obj_id=site.pk).first()
-        self.assertEqual(str(cfv.value), 'Option A')
+        site.refresh_from_db()
+        self.assertEqual(site.custom_field_data[cf.name], 'Option A')
 
 
         # Delete the stored value
         # Delete the stored value
-        cfv.value = None
-        cfv.save()
-        self.assertEqual(CustomFieldValue.objects.filter(obj_type=obj_type, obj_id=site.pk).count(), 0)
+        site.custom_field_data.pop(cf.name)
+        site.save()
+        site.refresh_from_db()
+        self.assertIsNone(site.custom_field_data.get(cf.name))
 
 
         # Delete the custom field
         # Delete the custom field
         cf.delete()
         cf.delete()
@@ -143,18 +139,10 @@ class CustomFieldAPITest(APITestCase):
         cls.cf_url.obj_type.set([content_type])
         cls.cf_url.obj_type.set([content_type])
 
 
         # Select custom field
         # Select custom field
-        cls.cf_select = CustomField(type=CustomFieldTypeChoices.TYPE_SELECT, name='choice_field')
+        cls.cf_select = CustomField(type=CustomFieldTypeChoices.TYPE_SELECT, name='choice_field', choices=['Foo', 'Bar', 'Baz'])
+        cls.cf_select.default = 'Foo'
         cls.cf_select.save()
         cls.cf_select.save()
         cls.cf_select.obj_type.set([content_type])
         cls.cf_select.obj_type.set([content_type])
-        cls.cf_select_choice1 = CustomFieldChoice(field=cls.cf_select, value='Foo')
-        cls.cf_select_choice1.save()
-        cls.cf_select_choice2 = CustomFieldChoice(field=cls.cf_select, value='Bar')
-        cls.cf_select_choice2.save()
-        cls.cf_select_choice3 = CustomFieldChoice(field=cls.cf_select, value='Baz')
-        cls.cf_select_choice3.save()
-
-        cls.cf_select.default = cls.cf_select_choice1.value
-        cls.cf_select.save()
 
 
         # Create some sites
         # Create some sites
         cls.sites = (
         cls.sites = (
@@ -164,20 +152,17 @@ class CustomFieldAPITest(APITestCase):
         Site.objects.bulk_create(cls.sites)
         Site.objects.bulk_create(cls.sites)
 
 
         # Assign custom field values for site 2
         # Assign custom field values for site 2
-        site2_cfvs = {
-            cls.cf_text: 'bar',
-            cls.cf_integer: 456,
-            cls.cf_boolean: True,
-            cls.cf_date: '2020-01-02',
-            cls.cf_url: 'http://example.com/2',
-            cls.cf_select: cls.cf_select_choice2.pk,
+        cls.sites[1].custom_field_data = {
+            cls.cf_text.name: 'bar',
+            cls.cf_integer.name: 456,
+            cls.cf_boolean.name: True,
+            cls.cf_date.name: '2020-01-02',
+            cls.cf_url.name: 'http://example.com/2',
+            cls.cf_select.name: 'Bar',
         }
         }
-        for field, value in site2_cfvs.items():
-            cfv = CustomFieldValue(field=field, obj=cls.sites[1])
-            cfv.value = value
-            cfv.save()
+        cls.sites[1].save()
 
 
-    def test_get_single_object_without_custom_field_values(self):
+    def test_get_single_object_without_custom_field_data(self):
         """
         """
         Validate that custom fields are present on an object even if it has no values defined.
         Validate that custom fields are present on an object even if it has no values defined.
         """
         """
@@ -195,13 +180,11 @@ class CustomFieldAPITest(APITestCase):
             'choice_field': None,
             'choice_field': None,
         })
         })
 
 
-    def test_get_single_object_with_custom_field_values(self):
+    def test_get_single_object_with_custom_field_data(self):
         """
         """
         Validate that custom fields are present and correctly set for an object with values defined.
         Validate that custom fields are present and correctly set for an object with values defined.
         """
         """
-        site2_cfvs = {
-            cfv.field.name: cfv.value for cfv in self.sites[1].custom_field_values.all()
-        }
+        site2_cfvs = self.sites[1].custom_field_data
         url = reverse('dcim-api:site-detail', kwargs={'pk': self.sites[1].pk})
         url = reverse('dcim-api:site-detail', kwargs={'pk': self.sites[1].pk})
         self.add_permissions('dcim.view_site')
         self.add_permissions('dcim.view_site')
 
 
@@ -212,7 +195,7 @@ class CustomFieldAPITest(APITestCase):
         self.assertEqual(response.data['custom_fields']['boolean_field'], site2_cfvs['boolean_field'])
         self.assertEqual(response.data['custom_fields']['boolean_field'], site2_cfvs['boolean_field'])
         self.assertEqual(response.data['custom_fields']['date_field'], site2_cfvs['date_field'])
         self.assertEqual(response.data['custom_fields']['date_field'], site2_cfvs['date_field'])
         self.assertEqual(response.data['custom_fields']['url_field'], site2_cfvs['url_field'])
         self.assertEqual(response.data['custom_fields']['url_field'], site2_cfvs['url_field'])
-        self.assertEqual(response.data['custom_fields']['choice_field']['label'], self.cf_select_choice2.value)
+        self.assertEqual(response.data['custom_fields']['choice_field'], site2_cfvs['choice_field'])
 
 
     def test_create_single_object_with_defaults(self):
     def test_create_single_object_with_defaults(self):
         """
         """
@@ -235,19 +218,16 @@ class CustomFieldAPITest(APITestCase):
         self.assertEqual(response_cf['boolean_field'], self.cf_boolean.default)
         self.assertEqual(response_cf['boolean_field'], self.cf_boolean.default)
         self.assertEqual(response_cf['date_field'], self.cf_date.default)
         self.assertEqual(response_cf['date_field'], self.cf_date.default)
         self.assertEqual(response_cf['url_field'], self.cf_url.default)
         self.assertEqual(response_cf['url_field'], self.cf_url.default)
-        self.assertEqual(response_cf['choice_field'], self.cf_select_choice1.pk)
+        self.assertEqual(response_cf['choice_field'], self.cf_select.default)
 
 
         # Validate database data
         # Validate database data
         site = Site.objects.get(pk=response.data['id'])
         site = Site.objects.get(pk=response.data['id'])
-        cfvs = {
-            cfv.field.name: cfv.value for cfv in site.custom_field_values.all()
-        }
-        self.assertEqual(cfvs['text_field'], self.cf_text.default)
-        self.assertEqual(cfvs['number_field'], self.cf_integer.default)
-        self.assertEqual(cfvs['boolean_field'], self.cf_boolean.default)
-        self.assertEqual(str(cfvs['date_field']), self.cf_date.default)
-        self.assertEqual(cfvs['url_field'], self.cf_url.default)
-        self.assertEqual(cfvs['choice_field'].pk, self.cf_select_choice1.pk)
+        self.assertEqual(site.custom_field_data['text_field'], self.cf_text.default)
+        self.assertEqual(site.custom_field_data['number_field'], self.cf_integer.default)
+        self.assertEqual(site.custom_field_data['boolean_field'], self.cf_boolean.default)
+        self.assertEqual(str(site.custom_field_data['date_field']), self.cf_date.default)
+        self.assertEqual(site.custom_field_data['url_field'], self.cf_url.default)
+        self.assertEqual(site.custom_field_data['choice_field'], self.cf_select.default)
 
 
     def test_create_single_object_with_values(self):
     def test_create_single_object_with_values(self):
         """
         """
@@ -262,7 +242,7 @@ class CustomFieldAPITest(APITestCase):
                 'boolean_field': True,
                 'boolean_field': True,
                 'date_field': '2020-01-02',
                 'date_field': '2020-01-02',
                 'url_field': 'http://example.com/2',
                 'url_field': 'http://example.com/2',
-                'choice_field': self.cf_select_choice2.pk,
+                'choice_field': 'Bar',
             },
             },
         }
         }
         url = reverse('dcim-api:site-list')
         url = reverse('dcim-api:site-list')
@@ -283,15 +263,12 @@ class CustomFieldAPITest(APITestCase):
 
 
         # Validate database data
         # Validate database data
         site = Site.objects.get(pk=response.data['id'])
         site = Site.objects.get(pk=response.data['id'])
-        cfvs = {
-            cfv.field.name: cfv.value for cfv in site.custom_field_values.all()
-        }
-        self.assertEqual(cfvs['text_field'], data_cf['text_field'])
-        self.assertEqual(cfvs['number_field'], data_cf['number_field'])
-        self.assertEqual(cfvs['boolean_field'], data_cf['boolean_field'])
-        self.assertEqual(str(cfvs['date_field']), data_cf['date_field'])
-        self.assertEqual(cfvs['url_field'], data_cf['url_field'])
-        self.assertEqual(cfvs['choice_field'].pk, data_cf['choice_field'])
+        self.assertEqual(site.custom_field_data['text_field'], data_cf['text_field'])
+        self.assertEqual(site.custom_field_data['number_field'], data_cf['number_field'])
+        self.assertEqual(site.custom_field_data['boolean_field'], data_cf['boolean_field'])
+        self.assertEqual(str(site.custom_field_data['date_field']), data_cf['date_field'])
+        self.assertEqual(site.custom_field_data['url_field'], data_cf['url_field'])
+        self.assertEqual(site.custom_field_data['choice_field'], data_cf['choice_field'])
 
 
     def test_create_multiple_objects_with_defaults(self):
     def test_create_multiple_objects_with_defaults(self):
         """
         """
@@ -328,19 +305,16 @@ class CustomFieldAPITest(APITestCase):
             self.assertEqual(response_cf['boolean_field'], self.cf_boolean.default)
             self.assertEqual(response_cf['boolean_field'], self.cf_boolean.default)
             self.assertEqual(response_cf['date_field'], self.cf_date.default)
             self.assertEqual(response_cf['date_field'], self.cf_date.default)
             self.assertEqual(response_cf['url_field'], self.cf_url.default)
             self.assertEqual(response_cf['url_field'], self.cf_url.default)
-            self.assertEqual(response_cf['choice_field'], self.cf_select_choice1.pk)
+            self.assertEqual(response_cf['choice_field'], self.cf_select.default)
 
 
             # Validate database data
             # Validate database data
             site = Site.objects.get(pk=response.data[i]['id'])
             site = Site.objects.get(pk=response.data[i]['id'])
-            cfvs = {
-                cfv.field.name: cfv.value for cfv in site.custom_field_values.all()
-            }
-            self.assertEqual(cfvs['text_field'], self.cf_text.default)
-            self.assertEqual(cfvs['number_field'], self.cf_integer.default)
-            self.assertEqual(cfvs['boolean_field'], self.cf_boolean.default)
-            self.assertEqual(str(cfvs['date_field']), self.cf_date.default)
-            self.assertEqual(cfvs['url_field'], self.cf_url.default)
-            self.assertEqual(cfvs['choice_field'].pk, self.cf_select_choice1.pk)
+            self.assertEqual(site.custom_field_data['text_field'], self.cf_text.default)
+            self.assertEqual(site.custom_field_data['number_field'], self.cf_integer.default)
+            self.assertEqual(site.custom_field_data['boolean_field'], self.cf_boolean.default)
+            self.assertEqual(str(site.custom_field_data['date_field']), self.cf_date.default)
+            self.assertEqual(site.custom_field_data['url_field'], self.cf_url.default)
+            self.assertEqual(site.custom_field_data['choice_field'], self.cf_select.default)
 
 
     def test_create_multiple_objects_with_values(self):
     def test_create_multiple_objects_with_values(self):
         """
         """
@@ -352,7 +326,7 @@ class CustomFieldAPITest(APITestCase):
             'boolean_field': True,
             'boolean_field': True,
             'date_field': '2020-01-02',
             'date_field': '2020-01-02',
             'url_field': 'http://example.com/2',
             'url_field': 'http://example.com/2',
-            'choice_field': self.cf_select_choice2.pk,
+            'choice_field': 'Bar',
         }
         }
         data = (
         data = (
             {
             {
@@ -391,24 +365,20 @@ class CustomFieldAPITest(APITestCase):
 
 
             # Validate database data
             # Validate database data
             site = Site.objects.get(pk=response.data[i]['id'])
             site = Site.objects.get(pk=response.data[i]['id'])
-            cfvs = {
-                cfv.field.name: cfv.value for cfv in site.custom_field_values.all()
-            }
-            self.assertEqual(cfvs['text_field'], custom_field_data['text_field'])
-            self.assertEqual(cfvs['number_field'], custom_field_data['number_field'])
-            self.assertEqual(cfvs['boolean_field'], custom_field_data['boolean_field'])
-            self.assertEqual(str(cfvs['date_field']), custom_field_data['date_field'])
-            self.assertEqual(cfvs['url_field'], custom_field_data['url_field'])
-            self.assertEqual(cfvs['choice_field'].pk, custom_field_data['choice_field'])
+            self.assertEqual(site.custom_field_data['text_field'], custom_field_data['text_field'])
+            self.assertEqual(site.custom_field_data['number_field'], custom_field_data['number_field'])
+            self.assertEqual(site.custom_field_data['boolean_field'], custom_field_data['boolean_field'])
+            self.assertEqual(str(site.custom_field_data['date_field']), custom_field_data['date_field'])
+            self.assertEqual(site.custom_field_data['url_field'], custom_field_data['url_field'])
+            self.assertEqual(site.custom_field_data['choice_field'], custom_field_data['choice_field'])
 
 
     def test_update_single_object_with_values(self):
     def test_update_single_object_with_values(self):
         """
         """
         Update an object with existing custom field values. Ensure that only the updated custom field values are
         Update an object with existing custom field values. Ensure that only the updated custom field values are
         modified.
         modified.
         """
         """
-        site2_original_cfvs = {
-            cfv.field.name: cfv.value for cfv in self.sites[1].custom_field_values.all()
-        }
+        site = self.sites[1]
+        original_cfvs = {**site.custom_field_data}
         data = {
         data = {
             'custom_fields': {
             'custom_fields': {
                 'text_field': 'ABCD',
                 'text_field': 'ABCD',
@@ -423,55 +393,21 @@ class CustomFieldAPITest(APITestCase):
 
 
         # Validate response data
         # Validate response data
         response_cf = response.data['custom_fields']
         response_cf = response.data['custom_fields']
-        data_cf = data['custom_fields']
-        self.assertEqual(response_cf['text_field'], data_cf['text_field'])
-        self.assertEqual(response_cf['number_field'], data_cf['number_field'])
-        # TODO: Non-updated fields are missing from the response data
-        # self.assertEqual(response_cf['boolean_field'], site2_original_cfvs['boolean_field'])
-        # self.assertEqual(response_cf['date_field'], site2_original_cfvs['date_field'])
-        # self.assertEqual(response_cf['url_field'], site2_original_cfvs['url_field'])
-        # self.assertEqual(response_cf['choice_field']['label'], site2_original_cfvs['choice_field'].value)
+        self.assertEqual(response_cf['text_field'], data['custom_fields']['text_field'])
+        self.assertEqual(response_cf['number_field'], data['custom_fields']['number_field'])
+        self.assertEqual(response_cf['boolean_field'], original_cfvs['boolean_field'])
+        self.assertEqual(response_cf['date_field'], original_cfvs['date_field'])
+        self.assertEqual(response_cf['url_field'], original_cfvs['url_field'])
+        self.assertEqual(response_cf['choice_field'], original_cfvs['choice_field'])
 
 
         # Validate database data
         # Validate database data
-        site2_updated_cfvs = {
-            cfv.field.name: cfv.value for cfv in self.sites[1].custom_field_values.all()
-        }
-        self.assertEqual(site2_updated_cfvs['text_field'], data_cf['text_field'])
-        self.assertEqual(site2_updated_cfvs['number_field'], data_cf['number_field'])
-        self.assertEqual(site2_updated_cfvs['boolean_field'], site2_original_cfvs['boolean_field'])
-        self.assertEqual(site2_updated_cfvs['date_field'], site2_original_cfvs['date_field'])
-        self.assertEqual(site2_updated_cfvs['url_field'], site2_original_cfvs['url_field'])
-        self.assertEqual(site2_updated_cfvs['choice_field'], site2_original_cfvs['choice_field'])
-
-
-class CustomFieldChoiceAPITest(APITestCase):
-    def setUp(self):
-        super().setUp()
-
-        vm_content_type = ContentType.objects.get_for_model(VirtualMachine)
-
-        self.cf_1 = CustomField.objects.create(name="cf_1", type=CustomFieldTypeChoices.TYPE_SELECT)
-        self.cf_2 = CustomField.objects.create(name="cf_2", type=CustomFieldTypeChoices.TYPE_SELECT)
-
-        self.cf_choice_1 = CustomFieldChoice.objects.create(field=self.cf_1, value="cf_field_1", weight=100)
-        self.cf_choice_2 = CustomFieldChoice.objects.create(field=self.cf_1, value="cf_field_2", weight=50)
-        self.cf_choice_3 = CustomFieldChoice.objects.create(field=self.cf_2, value="cf_field_3", weight=10)
-
-    def test_list_cfc(self):
-        url = reverse('extras-api:custom-field-choice-list')
-        response = self.client.get(url, **self.header)
-
-        self.assertEqual(len(response.data), 2)
-        self.assertEqual(len(response.data[self.cf_1.name]), 2)
-        self.assertEqual(len(response.data[self.cf_2.name]), 1)
-
-        self.assertTrue(self.cf_choice_1.value in response.data[self.cf_1.name])
-        self.assertTrue(self.cf_choice_2.value in response.data[self.cf_1.name])
-        self.assertTrue(self.cf_choice_3.value in response.data[self.cf_2.name])
-
-        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_3.pk, response.data[self.cf_2.name][self.cf_choice_3.value])
+        site.refresh_from_db()
+        self.assertEqual(site.custom_field_data['text_field'], data['custom_fields']['text_field'])
+        self.assertEqual(site.custom_field_data['number_field'], data['custom_fields']['number_field'])
+        self.assertEqual(site.custom_field_data['boolean_field'], original_cfvs['boolean_field'])
+        self.assertEqual(site.custom_field_data['date_field'], original_cfvs['date_field'])
+        self.assertEqual(site.custom_field_data['url_field'], original_cfvs['url_field'])
+        self.assertEqual(site.custom_field_data['choice_field'], original_cfvs['choice_field'])
 
 
 
 
 class CustomFieldImportTest(TestCase):
 class CustomFieldImportTest(TestCase):
@@ -489,18 +425,12 @@ class CustomFieldImportTest(TestCase):
             CustomField(name='boolean', type=CustomFieldTypeChoices.TYPE_BOOLEAN),
             CustomField(name='boolean', type=CustomFieldTypeChoices.TYPE_BOOLEAN),
             CustomField(name='date', type=CustomFieldTypeChoices.TYPE_DATE),
             CustomField(name='date', type=CustomFieldTypeChoices.TYPE_DATE),
             CustomField(name='url', type=CustomFieldTypeChoices.TYPE_URL),
             CustomField(name='url', type=CustomFieldTypeChoices.TYPE_URL),
-            CustomField(name='select', type=CustomFieldTypeChoices.TYPE_SELECT),
+            CustomField(name='select', type=CustomFieldTypeChoices.TYPE_SELECT, choices=['Choice A', 'Choice B', 'Choice C']),
         )
         )
         for cf in custom_fields:
         for cf in custom_fields:
             cf.save()
             cf.save()
             cf.obj_type.set([ContentType.objects.get_for_model(Site)])
             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):
     def test_import(self):
         """
         """
         Import a Site in CSV format, including a value for each CustomField.
         Import a Site in CSV format, including a value for each CustomField.
@@ -517,34 +447,28 @@ class CustomFieldImportTest(TestCase):
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
         # Validate data for site 1
         # 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')
+        site1 = Site.objects.get(name='Site 1')
+        self.assertEqual(len(site1.custom_field_data), 6)
+        self.assertEqual(site1.custom_field_data['text'], 'ABC')
+        self.assertEqual(site1.custom_field_data['integer'], 123)
+        self.assertEqual(site1.custom_field_data['boolean'], True)
+        self.assertEqual(site1.custom_field_data['date'], '2020-01-01')
+        self.assertEqual(site1.custom_field_data['url'], 'http://example.com/1')
+        self.assertEqual(site1.custom_field_data['select'], 'Choice A')
 
 
         # Validate data for site 2
         # 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)
+        site2 = Site.objects.get(name='Site 2')
+        self.assertEqual(len(site2.custom_field_data), 6)
+        self.assertEqual(site2.custom_field_data['text'], 'DEF')
+        self.assertEqual(site2.custom_field_data['integer'], 456)
+        self.assertEqual(site2.custom_field_data['boolean'], False)
+        self.assertEqual(site2.custom_field_data['date'], '2020-01-02')
+        self.assertEqual(site2.custom_field_data['url'], 'http://example.com/2')
+        self.assertEqual(site2.custom_field_data['select'], 'Choice B')
+
+        # No custom field data should be set for site 3
         site3 = Site.objects.get(name='Site 3')
         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
+        self.assertFalse(any(site3.custom_field_data.values()))
 
 
     def test_import_missing_required(self):
     def test_import_missing_required(self):
         """
         """

+ 42 - 0
netbox/ipam/migrations/0038_custom_field_data.py

@@ -0,0 +1,42 @@
+import django.core.serializers.json
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('ipam', '0037_ipaddress_assignment'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='aggregate',
+            name='custom_field_data',
+            field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder),
+        ),
+        migrations.AddField(
+            model_name='ipaddress',
+            name='custom_field_data',
+            field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder),
+        ),
+        migrations.AddField(
+            model_name='prefix',
+            name='custom_field_data',
+            field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder),
+        ),
+        migrations.AddField(
+            model_name='service',
+            name='custom_field_data',
+            field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder),
+        ),
+        migrations.AddField(
+            model_name='vlan',
+            name='custom_field_data',
+            field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder),
+        ),
+        migrations.AddField(
+            model_name='vrf',
+            name='custom_field_data',
+            field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder),
+        ),
+    ]

+ 1 - 31
netbox/ipam/models.py

@@ -1,6 +1,6 @@
 import netaddr
 import netaddr
 from django.conf import settings
 from django.conf import settings
-from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
+from django.contrib.contenttypes.fields import GenericForeignKey
 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.core.validators import MaxValueValidator, MinValueValidator
 from django.core.validators import MaxValueValidator, MinValueValidator
@@ -70,11 +70,6 @@ class VRF(ChangeLoggedModel, CustomFieldModel):
         max_length=200,
         max_length=200,
         blank=True
         blank=True
     )
     )
-    custom_field_values = GenericRelation(
-        to='extras.CustomFieldValue',
-        content_type_field='obj_type',
-        object_id_field='obj_id'
-    )
     tags = TaggableManager(through=TaggedItem)
     tags = TaggableManager(through=TaggedItem)
 
 
     objects = RestrictedQuerySet.as_manager()
     objects = RestrictedQuerySet.as_manager()
@@ -178,11 +173,6 @@ class Aggregate(ChangeLoggedModel, CustomFieldModel):
         max_length=200,
         max_length=200,
         blank=True
         blank=True
     )
     )
-    custom_field_values = GenericRelation(
-        to='extras.CustomFieldValue',
-        content_type_field='obj_type',
-        object_id_field='obj_id'
-    )
     tags = TaggableManager(through=TaggedItem)
     tags = TaggableManager(through=TaggedItem)
 
 
     objects = RestrictedQuerySet.as_manager()
     objects = RestrictedQuerySet.as_manager()
@@ -364,11 +354,6 @@ class Prefix(ChangeLoggedModel, CustomFieldModel):
         max_length=200,
         max_length=200,
         blank=True
         blank=True
     )
     )
-    custom_field_values = GenericRelation(
-        to='extras.CustomFieldValue',
-        content_type_field='obj_type',
-        object_id_field='obj_id'
-    )
     tags = TaggableManager(through=TaggedItem)
     tags = TaggableManager(through=TaggedItem)
 
 
     objects = PrefixQuerySet.as_manager()
     objects = PrefixQuerySet.as_manager()
@@ -647,11 +632,6 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
         max_length=200,
         max_length=200,
         blank=True
         blank=True
     )
     )
-    custom_field_values = GenericRelation(
-        to='extras.CustomFieldValue',
-        content_type_field='obj_type',
-        object_id_field='obj_id'
-    )
     tags = TaggableManager(through=TaggedItem)
     tags = TaggableManager(through=TaggedItem)
 
 
     objects = IPAddressManager()
     objects = IPAddressManager()
@@ -935,11 +915,6 @@ class VLAN(ChangeLoggedModel, CustomFieldModel):
         max_length=200,
         max_length=200,
         blank=True
         blank=True
     )
     )
-    custom_field_values = GenericRelation(
-        to='extras.CustomFieldValue',
-        content_type_field='obj_type',
-        object_id_field='obj_id'
-    )
     tags = TaggableManager(through=TaggedItem)
     tags = TaggableManager(through=TaggedItem)
 
 
     objects = RestrictedQuerySet.as_manager()
     objects = RestrictedQuerySet.as_manager()
@@ -1050,11 +1025,6 @@ class Service(ChangeLoggedModel, CustomFieldModel):
         max_length=200,
         max_length=200,
         blank=True
         blank=True
     )
     )
-    custom_field_values = GenericRelation(
-        to='extras.CustomFieldValue',
-        content_type_field='obj_type',
-        object_id_field='obj_id'
-    )
     tags = TaggableManager(through=TaggedItem)
     tags = TaggableManager(through=TaggedItem)
 
 
     objects = RestrictedQuerySet.as_manager()
     objects = RestrictedQuerySet.as_manager()

+ 2 - 1
netbox/netbox/settings.py

@@ -483,9 +483,10 @@ REST_FRAMEWORK = {
 SWAGGER_SETTINGS = {
 SWAGGER_SETTINGS = {
     'DEFAULT_AUTO_SCHEMA_CLASS': 'utilities.custom_inspectors.NetBoxSwaggerAutoSchema',
     'DEFAULT_AUTO_SCHEMA_CLASS': 'utilities.custom_inspectors.NetBoxSwaggerAutoSchema',
     'DEFAULT_FIELD_INSPECTORS': [
     'DEFAULT_FIELD_INSPECTORS': [
+        'utilities.custom_inspectors.CustomFieldsDataFieldInspector',
         'utilities.custom_inspectors.JSONFieldInspector',
         'utilities.custom_inspectors.JSONFieldInspector',
         'utilities.custom_inspectors.NullableBooleanFieldInspector',
         'utilities.custom_inspectors.NullableBooleanFieldInspector',
-        'utilities.custom_inspectors.CustomChoiceFieldInspector',
+        'utilities.custom_inspectors.ChoiceFieldInspector',
         'utilities.custom_inspectors.SerializedPKRelatedFieldInspector',
         'utilities.custom_inspectors.SerializedPKRelatedFieldInspector',
         'drf_yasg.inspectors.CamelCaseJSONFilter',
         'drf_yasg.inspectors.CamelCaseJSONFilter',
         'drf_yasg.inspectors.ReferencingSerializerInspector',
         'drf_yasg.inspectors.ReferencingSerializerInspector',

+ 17 - 0
netbox/secrets/migrations/0010_custom_field_data.py

@@ -0,0 +1,17 @@
+import django.core.serializers.json
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('secrets', '0009_secretrole_drop_users_groups'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='secret',
+            name='custom_field_data',
+            field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder),
+        ),
+    ]

+ 0 - 6
netbox/secrets/models.py

@@ -6,7 +6,6 @@ from Crypto.Util import strxor
 from django.conf import settings
 from django.conf import settings
 from django.contrib.auth.hashers import make_password, check_password
 from django.contrib.auth.hashers import make_password, check_password
 from django.contrib.auth.models import Group, User
 from django.contrib.auth.models import Group, User
-from django.contrib.contenttypes.fields import GenericRelation
 from django.core.exceptions import ValidationError
 from django.core.exceptions import ValidationError
 from django.db import models
 from django.db import models
 from django.urls import reverse
 from django.urls import reverse
@@ -306,11 +305,6 @@ class Secret(ChangeLoggedModel, CustomFieldModel):
         max_length=128,
         max_length=128,
         editable=False
         editable=False
     )
     )
-    custom_field_values = GenericRelation(
-        to='extras.CustomFieldValue',
-        content_type_field='obj_type',
-        object_id_field='obj_id'
-    )
     tags = TaggableManager(through=TaggedItem)
     tags = TaggableManager(through=TaggedItem)
 
 
     objects = RestrictedQuerySet.as_manager()
     objects = RestrictedQuerySet.as_manager()

+ 17 - 0
netbox/tenancy/migrations/0010_custom_field_data.py

@@ -0,0 +1,17 @@
+import django.core.serializers.json
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('tenancy', '0009_standardize_description'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='tenant',
+            name='custom_field_data',
+            field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder),
+        ),
+    ]

+ 0 - 6
netbox/tenancy/models.py

@@ -1,4 +1,3 @@
-from django.contrib.contenttypes.fields import GenericRelation
 from django.db import models
 from django.db import models
 from django.urls import reverse
 from django.urls import reverse
 from mptt.models import MPTTModel, TreeForeignKey
 from mptt.models import MPTTModel, TreeForeignKey
@@ -102,11 +101,6 @@ class Tenant(ChangeLoggedModel, CustomFieldModel):
     comments = models.TextField(
     comments = models.TextField(
         blank=True
         blank=True
     )
     )
-    custom_field_values = GenericRelation(
-        to='extras.CustomFieldValue',
-        content_type_field='obj_type',
-        object_id_field='obj_id'
-    )
     tags = TaggableManager(through=TaggedItem)
     tags = TaggableManager(through=TaggedItem)
 
 
     objects = RestrictedQuerySet.as_manager()
     objects = RestrictedQuerySet.as_manager()

+ 13 - 6
netbox/utilities/custom_inspectors.py

@@ -5,7 +5,7 @@ from drf_yasg.utils import get_serializer_ref_name
 from rest_framework.fields import ChoiceField
 from rest_framework.fields import ChoiceField
 from rest_framework.relations import ManyRelatedField
 from rest_framework.relations import ManyRelatedField
 
 
-from extras.api.customfields import CustomFieldsSerializer
+from extras.api.customfields import CustomFieldsDataField
 from utilities.api import ChoiceField, SerializedPKRelatedField, WritableNestedSerializer
 from utilities.api import ChoiceField, SerializedPKRelatedField, WritableNestedSerializer
 
 
 
 
@@ -49,7 +49,7 @@ class SerializedPKRelatedFieldInspector(FieldInspector):
         return NotHandled
         return NotHandled
 
 
 
 
-class CustomChoiceFieldInspector(FieldInspector):
+class ChoiceFieldInspector(FieldInspector):
     def field_to_swagger_object(self, field, swagger_object_type, use_references, **kwargs):
     def field_to_swagger_object(self, field, swagger_object_type, use_references, **kwargs):
         # this returns a callable which extracts title, description and other stuff
         # this returns a callable which extracts title, description and other stuff
         # https://drf-yasg.readthedocs.io/en/stable/_modules/drf_yasg/inspectors/base.html#FieldInspector._get_partial_types
         # https://drf-yasg.readthedocs.io/en/stable/_modules/drf_yasg/inspectors/base.html#FieldInspector._get_partial_types
@@ -83,10 +83,6 @@ class CustomChoiceFieldInspector(FieldInspector):
 
 
             return schema
             return schema
 
 
-        elif isinstance(field, CustomFieldsSerializer):
-            schema = SwaggerType(type=openapi.TYPE_OBJECT)
-            return schema
-
         return NotHandled
         return NotHandled
 
 
 
 
@@ -102,6 +98,17 @@ class NullableBooleanFieldInspector(FieldInspector):
         return result
         return result
 
 
 
 
+class CustomFieldsDataFieldInspector(FieldInspector):
+
+    def field_to_swagger_object(self, field, swagger_object_type, use_references, **kwargs):
+        SwaggerType, ChildSwaggerType = self._get_partial_types(field, swagger_object_type, use_references, **kwargs)
+
+        if isinstance(field, CustomFieldsDataField) and swagger_object_type == openapi.Schema:
+            return SwaggerType(type=openapi.TYPE_OBJECT)
+
+        return NotHandled
+
+
 class JSONFieldInspector(FieldInspector):
 class JSONFieldInspector(FieldInspector):
     """Required because by default, Swagger sees a JSONField as a string and not dict
     """Required because by default, Swagger sees a JSONField as a string and not dict
     """
     """

+ 3 - 5
netbox/utilities/utils.py

@@ -91,11 +91,9 @@ def serialize_object(obj, extra=None, exclude=None):
     json_str = serialize('json', [obj])
     json_str = serialize('json', [obj])
     data = json.loads(json_str)[0]['fields']
     data = json.loads(json_str)[0]['fields']
 
 
-    # Include any custom fields
-    if hasattr(obj, 'get_custom_fields'):
-        data['custom_fields'] = {
-            field: str(value) for field, value in obj.cf.items()
-        }
+    # Include custom_field_data as "custom_fields"
+    if hasattr(obj, 'custom_field_data'):
+        data['custom_fields'] = data.pop('custom_field_data')
 
 
     # Include any tags. Check for tags cached on the instance; fall back to using the manager.
     # Include any tags. Check for tags cached on the instance; fall back to using the manager.
     if is_taggable(obj):
     if is_taggable(obj):

+ 10 - 39
netbox/utilities/views.py

@@ -28,8 +28,7 @@ from django.views.defaults import ERROR_500_TEMPLATE_NAME
 from django.views.generic import View
 from django.views.generic import View
 from django_tables2 import RequestConfig
 from django_tables2 import RequestConfig
 
 
-from extras.models import CustomField, CustomFieldValue, ExportTemplate
-from extras.querysets import CustomFieldQueryset
+from extras.models import CustomField, ExportTemplate
 from utilities.exceptions import AbortTransaction
 from utilities.exceptions import AbortTransaction
 from utilities.forms import BootstrapMixin, BulkRenameForm, CSVDataField, TableConfigForm, restrict_form_fields
 from utilities.forms import BootstrapMixin, BulkRenameForm, CSVDataField, TableConfigForm, restrict_form_fields
 from utilities.permissions import get_permission_for_model, resolve_permission
 from utilities.permissions import get_permission_for_model, resolve_permission
@@ -231,8 +230,8 @@ class ObjectListView(ObjectPermissionRequiredMixin, View):
         headers = self.queryset.model.csv_headers.copy()
         headers = self.queryset.model.csv_headers.copy()
 
 
         # Add custom field headers, if any
         # Add custom field headers, if any
-        if hasattr(self.queryset.model, 'get_custom_fields'):
-            for custom_field in self.queryset.model().get_custom_fields():
+        if hasattr(self.queryset.model, 'custom_field_data'):
+            for custom_field in CustomField.objects.get_for_model(self.queryset.model):
                 headers.append(custom_field.name)
                 headers.append(custom_field.name)
                 custom_fields.append(custom_field.name)
                 custom_fields.append(custom_field.name)
 
 
@@ -257,19 +256,11 @@ class ObjectListView(ObjectPermissionRequiredMixin, View):
         if self.filterset:
         if self.filterset:
             self.queryset = self.filterset(request.GET, self.queryset).qs
             self.queryset = self.filterset(request.GET, self.queryset).qs
 
 
-        # If this type of object has one or more custom fields, prefetch any relevant custom field values
-        custom_fields = CustomField.objects.filter(
-            obj_type=ContentType.objects.get_for_model(model)
-        ).prefetch_related('choices')
-        if custom_fields:
-            self.queryset = self.queryset.prefetch_related('custom_field_values')
-
         # Check for export template rendering
         # Check for export template rendering
         if request.GET.get('export'):
         if request.GET.get('export'):
             et = get_object_or_404(ExportTemplate, content_type=content_type, name=request.GET.get('export'))
             et = get_object_or_404(ExportTemplate, content_type=content_type, name=request.GET.get('export'))
-            queryset = CustomFieldQueryset(self.queryset, custom_fields) if custom_fields else self.queryset
             try:
             try:
-                return et.render_to_response(queryset)
+                return et.render_to_response(self.queryset)
             except Exception as e:
             except Exception as e:
                 messages.error(
                 messages.error(
                     request,
                     request,
@@ -951,38 +942,18 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
                                 elif form.cleaned_data[name] not in (None, ''):
                                 elif form.cleaned_data[name] not in (None, ''):
                                     setattr(obj, name, form.cleaned_data[name])
                                     setattr(obj, name, form.cleaned_data[name])
 
 
-                            # Cache custom fields on instance prior to save()
-                            if custom_fields:
-                                obj._cf = {
-                                    name: form.cleaned_data[name] for name in custom_fields
-                                }
+                            # Update custom fields
+                            for name in custom_fields:
+                                if name in form.nullable_fields and name in nullified_fields:
+                                    obj.custom_field_data.pop(name, None)
+                                else:
+                                    obj.custom_field_data[name] = form.cleaned_data[name]
 
 
                             obj.full_clean()
                             obj.full_clean()
                             obj.save()
                             obj.save()
                             updated_objects.append(obj)
                             updated_objects.append(obj)
                             logger.debug(f"Saved {obj} (PK: {obj.pk})")
                             logger.debug(f"Saved {obj} (PK: {obj.pk})")
 
 
-                            # Update custom fields
-                            obj_type = ContentType.objects.get_for_model(model)
-                            for name in custom_fields:
-                                field = form.fields[name].model
-                                if name in form.nullable_fields and name in nullified_fields:
-                                    CustomFieldValue.objects.filter(
-                                        field=field, obj_type=obj_type, obj_id=obj.pk
-                                    ).delete()
-                                elif form.cleaned_data[name] not in [None, '']:
-                                    try:
-                                        cfv = CustomFieldValue.objects.get(
-                                            field=field, obj_type=obj_type, obj_id=obj.pk
-                                        )
-                                    except CustomFieldValue.DoesNotExist:
-                                        cfv = CustomFieldValue(
-                                            field=field, obj_type=obj_type, obj_id=obj.pk
-                                        )
-                                    cfv.value = form.cleaned_data[name]
-                                    cfv.save()
-                            logger.debug(f"Saved custom fields for {obj} (PK: {obj.pk})")
-
                             # Add/remove tags
                             # Add/remove tags
                             if form.cleaned_data.get('add_tags', None):
                             if form.cleaned_data.get('add_tags', None):
                                 obj.tags.add(*form.cleaned_data['add_tags'])
                                 obj.tags.add(*form.cleaned_data['add_tags'])

+ 22 - 0
netbox/virtualization/migrations/0018_custom_field_data.py

@@ -0,0 +1,22 @@
+import django.core.serializers.json
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('virtualization', '0017_update_jsonfield'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='cluster',
+            name='custom_field_data',
+            field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder),
+        ),
+        migrations.AddField(
+            model_name='virtualmachine',
+            name='custom_field_data',
+            field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder),
+        ),
+    ]

+ 0 - 10
netbox/virtualization/models.py

@@ -150,11 +150,6 @@ class Cluster(ChangeLoggedModel, CustomFieldModel):
     comments = models.TextField(
     comments = models.TextField(
         blank=True
         blank=True
     )
     )
-    custom_field_values = GenericRelation(
-        to='extras.CustomFieldValue',
-        content_type_field='obj_type',
-        object_id_field='obj_id'
-    )
     tags = TaggableManager(through=TaggedItem)
     tags = TaggableManager(through=TaggedItem)
 
 
     objects = RestrictedQuerySet.as_manager()
     objects = RestrictedQuerySet.as_manager()
@@ -275,11 +270,6 @@ class VirtualMachine(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
     comments = models.TextField(
     comments = models.TextField(
         blank=True
         blank=True
     )
     )
-    custom_field_values = GenericRelation(
-        to='extras.CustomFieldValue',
-        content_type_field='obj_type',
-        object_id_field='obj_id'
-    )
     tags = TaggableManager(through=TaggedItem)
     tags = TaggableManager(through=TaggedItem)
 
 
     objects = RestrictedQuerySet.as_manager()
     objects = RestrictedQuerySet.as_manager()