Jeremy Stretch 5 лет назад
Родитель
Сommit
2276603ac3

+ 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: 123 (BetterName)>
 >>> 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.
@@ -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()
 27
 >>> 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

+ 0 - 11
netbox/circuits/models.py

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

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

@@ -134,11 +134,6 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel):
     comments = models.TextField(
         blank=True
     )
-    custom_field_values = GenericRelation(
-        to='extras.CustomFieldValue',
-        content_type_field='obj_type',
-        object_id_field='obj_id'
-    )
     tags = TaggableManager(through=TaggedItem)
 
     objects = RestrictedQuerySet.as_manager()
@@ -584,11 +579,6 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
     comments = models.TextField(
         blank=True
     )
-    custom_field_values = GenericRelation(
-        to='extras.CustomFieldValue',
-        content_type_field='obj_type',
-        object_id_field='obj_id'
-    )
     images = GenericRelation(
         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.validators import MaxValueValidator, MinValueValidator
 from django.db import models
@@ -144,11 +143,6 @@ class PowerFeed(ChangeLoggedModel, CableTermination, CustomFieldModel):
     comments = models.TextField(
         blank=True
     )
-    custom_field_values = GenericRelation(
-        to='extras.CustomFieldValue',
-        content_type_field='obj_type',
-        object_id_field='obj_id'
-    )
     tags = TaggableManager(through=TaggedItem)
 
     objects = RestrictedQuerySet.as_manager()

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

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

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

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

+ 2 - 9
netbox/extras/api/customfields.py

@@ -8,7 +8,7 @@ from rest_framework.exceptions import ValidationError
 from rest_framework.fields import CreateOnlyDefault
 
 from extras.choices import *
-from extras.models import CustomField, CustomFieldChoice, CustomFieldValue
+from extras.models import CustomField, CustomFieldChoice
 from utilities.api import ValidatedModelSerializer
 
 
@@ -164,15 +164,8 @@ class CustomFieldModelSerializer(ValidatedModelSerializer):
                 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)},
-            )
+            instance.custom_field_data[field_name] = value
 
     def create(self, validated_data):
 

+ 0 - 4
netbox/extras/api/views.py

@@ -93,10 +93,6 @@ class CustomFieldModelViewSet(ModelViewSet):
         })
         return context
 
-    def get_queryset(self):
-        # Prefetch custom field values
-        return super().get_queryset().prefetch_related('custom_field_values__field')
-
 
 #
 # Export templates

+ 3 - 23
netbox/extras/forms.py

@@ -12,7 +12,7 @@ from utilities.forms import (
 )
 from virtualization.models import Cluster, ClusterGroup
 from .choices import *
-from .models import ConfigContext, CustomField, CustomFieldValue, ImageAttachment, ObjectChange, Tag
+from .models import ConfigContext, CustomField, ImageAttachment, ObjectChange, Tag
 
 
 #
@@ -40,11 +40,7 @@ class CustomFieldModelForm(forms.ModelForm):
         """
         # 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
+            self.custom_field_values = self.instance.custom_field_data
 
         # Append form fields; assign initial values if modifying and existing object
         for cf in CustomField.objects.filter(obj_type=self.obj_type):
@@ -64,23 +60,7 @@ class CustomFieldModelForm(forms.ModelForm):
     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()
+            self.instance.custom_field_data[field_name[3:]] = self.cleaned_data[field_name]
 
     def save(self, commit=True):
 

+ 16 - 0
netbox/extras/migrations/0051_delete_customfieldvalue.py

@@ -0,0 +1,16 @@
+# Generated by Django 3.1 on 2020-08-21 19:52
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('extras', '0050_migrate_customfieldvalues'),
+    ]
+
+    operations = [
+        migrations.DeleteModel(
+            name='CustomFieldValue',
+        ),
+    ]

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

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

+ 12 - 79
netbox/extras/models/customfields.py

@@ -1,8 +1,6 @@
-from collections import OrderedDict
 from datetime import date
 
 from django import forms
-from django.contrib.contenttypes.fields import GenericForeignKey
 from django.contrib.contenttypes.models import ContentType
 from django.core.validators import ValidationError
 from django.db import models
@@ -29,36 +27,12 @@ class CustomFieldModel(models.Model):
         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
     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
-
-    def get_custom_fields(self):
-        """
-        Return a dictionary of custom fields for a single object in the form {<field>: value}.
-        """
-        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 self.custom_field_data
 
 
 class CustomFieldManager(models.Manager):
@@ -235,49 +209,6 @@ class CustomField(models.Model):
         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',
@@ -304,11 +235,13 @@ class CustomFieldChoice(models.Model):
         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()
+    def delete(self, *args, **kwargs):
+        # TODO: Prevent deletion of CustomFieldChoices which are in use?
+        field_name = f'custom_field_data__{self.field.name}'
+        for ct in self.field.obj_type.all():
+            model = ct.model_class()
+            for instance in model.objects.filter(**{field_name: self.pk}):
+                instance.custom_field_data.pop(self.field.name)
+                instance.save()
+
+        super().delete(*args, **kwargs)

+ 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
 
 
-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):
 
     def get_for_object(self, obj):

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

@@ -5,7 +5,7 @@ from rest_framework import status
 from dcim.choices import SiteStatusChoices
 from dcim.models import Site
 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.utils import post_data
 from utilities.testing.views import ModelViewTestCase
@@ -93,16 +93,14 @@ class ChangeLogViewTest(ModelViewTestCase):
     def test_delete_object(self):
         site = Site(
             name='Test Site 1',
-            slug='test-site-1'
+            slug='test-site-1',
+            custom_field_data={
+                'my_field': 'ABC'
+            }
         )
         site.save()
         self.create_tags('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 = {
             'path': self._get_url('delete', instance=site),
@@ -209,15 +207,13 @@ class ChangeLogAPITest(APITestCase):
     def test_delete_object(self):
         site = Site(
             name='Test Site 1',
-            slug='test-site-1'
+            slug='test-site-1',
+            custom_field_data={
+                'my_field': 'ABC'
+            }
         )
         site.save()
         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.add_permissions('dcim.delete_site')
         url = reverse('dcim-api:site-detail', kwargs={'pk': site.pk})

+ 28 - 32
netbox/extras/tests/test_customfields.py

@@ -7,7 +7,7 @@ from rest_framework import status
 from dcim.forms import SiteCSVForm
 from dcim.models import Site
 from extras.choices import *
-from extras.models import CustomField, CustomFieldValue, CustomFieldChoice
+from extras.models import CustomField, CustomFieldChoice
 from utilities.testing import APITestCase, TestCase
 from virtualization.models import VirtualMachine
 
@@ -46,18 +46,18 @@ class CustomFieldTest(TestCase):
 
             # Assign a value to the first Site
             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
-            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
-            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
             cf.delete()
@@ -81,18 +81,18 @@ class CustomFieldTest(TestCase):
 
         # Assign a value to the first Site
         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] = cf.choices.first().pk
+        site.save()
 
         # 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
-        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
         cf.delete()
@@ -164,18 +164,15 @@ class CustomFieldAPITest(APITestCase):
         Site.objects.bulk_create(cls.sites)
 
         # 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: cls.cf_select_choice2.pk,
         }
-        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):
         """
@@ -518,7 +515,7 @@ class CustomFieldImportTest(TestCase):
 
         # 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()
+            cf.name: value for cf, value in Site.objects.get(name='Site 1').custom_field_data
         }
         self.assertEqual(len(custom_field_values), 6)
         self.assertEqual(custom_field_values['text'], 'ABC')
@@ -530,7 +527,7 @@ class CustomFieldImportTest(TestCase):
 
         # 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()
+            cf.name: value for cf, value in Site.objects.get(name='Site 2').custom_field_data
         }
         self.assertEqual(len(custom_field_values), 6)
         self.assertEqual(custom_field_values['text'], 'DEF')
@@ -543,8 +540,7 @@ class CustomFieldImportTest(TestCase):
         # No CustomFieldValues should be created for site 3
         obj_type = ContentType.objects.get_for_model(Site)
         site3 = Site.objects.get(name='Site 3')
-        self.assertFalse(CustomFieldValue.objects.filter(obj_type=obj_type, obj_id=site3.pk).exists())
-        self.assertEqual(CustomFieldValue.objects.count(), 12)  # Sanity check
+        self.assertEqual(site3.custom_field_data, {})
 
     def test_import_missing_required(self):
         """

+ 1 - 31
netbox/ipam/models.py

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

+ 0 - 6
netbox/secrets/models.py

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

+ 0 - 6
netbox/tenancy/models.py

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

+ 10 - 39
netbox/utilities/views.py

@@ -26,8 +26,7 @@ from django.views.defaults import ERROR_500_TEMPLATE_NAME
 from django.views.generic import View
 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.forms import BootstrapMixin, BulkRenameForm, CSVDataField, TableConfigForm, restrict_form_fields
 from utilities.permissions import get_permission_for_model, resolve_permission
@@ -229,8 +228,8 @@ class ObjectListView(ObjectPermissionRequiredMixin, View):
         headers = self.queryset.model.csv_headers.copy()
 
         # Add custom field headers, if any
-        if hasattr(self.queryset.model, 'get_custom_fields'):
-            for custom_field in self.queryset.model().get_custom_fields():
+        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)
                 custom_fields.append(custom_field.name)
 
@@ -255,19 +254,11 @@ class ObjectListView(ObjectPermissionRequiredMixin, View):
         if self.filterset:
             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
         if 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:
-                return et.render_to_response(queryset)
+                return et.render_to_response(self.queryset)
             except Exception as e:
                 messages.error(
                     request,
@@ -949,38 +940,18 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
                                 elif form.cleaned_data[name] not in (None, ''):
                                     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.save()
                             updated_objects.append(obj)
                             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
                             if form.cleaned_data.get('add_tags', None):
                                 obj.tags.add(*form.cleaned_data['add_tags'])

+ 0 - 10
netbox/virtualization/models.py

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