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

Closes #10052: The cf attribute now returns deserialized custom field data

jeremystretch 3 лет назад
Родитель
Сommit
ea6d86e6c4

+ 2 - 0
docs/release-notes/version-3.4.md

@@ -9,6 +9,7 @@
 * The `asn` field has been removed from the provider model. Please replicate any provider ASN assignments to the ASN model introduced in NetBox v3.1 prior to upgrading.
 * The `noc_contact`, `admin_contact`, and `portal_url` fields have been removed from the provider model. Please replicate any data remaining in these fields to the contact model introduced in NetBox v3.1 prior to upgrading.
 * The `content_type` field on the CustomLink and ExportTemplate models have been renamed to `content_types` and now supports the assignment of multiple content types.
+* The `cf` property on an object with custom fields now returns deserialized values. For example, a custom field referencing an object will return the object instance rather than its numeric ID. To access the raw serialized values, use `custom_field_data` instead.
 
 ### New Features
 
@@ -37,6 +38,7 @@ A new `PluginMenu` class has been introduced, which enables a plugin to inject a
 * [#9817](https://github.com/netbox-community/netbox/issues/9817) - Add `assigned_object` field to GraphQL type for IP addresses and L2VPN terminations
 * [#9832](https://github.com/netbox-community/netbox/issues/9832) - Add `mounting_depth` field to rack model
 * [#9892](https://github.com/netbox-community/netbox/issues/9892) - Add optional `name` field for FHRP groups
+* [#10052](https://github.com/netbox-community/netbox/issues/10052) - The `cf` attribute now returns deserialized custom field data
 * [#10348](https://github.com/netbox-community/netbox/issues/10348) - Add decimal custom field type
 * [#10556](https://github.com/netbox-community/netbox/issues/10556) - Include a `display` field in all GraphQL object types
 * [#10595](https://github.com/netbox-community/netbox/issues/10595) - Add GraphQL relationships for additional generic foreign key fields

+ 6 - 6
netbox/extras/tests/test_customfields.py

@@ -1022,7 +1022,7 @@ class CustomFieldModelTest(TestCase):
         site = Site(name='Test Site', slug='test-site')
 
         # Check custom field data on new instance
-        site.cf['foo'] = 'abc'
+        site.custom_field_data['foo'] = 'abc'
         self.assertEqual(site.cf['foo'], 'abc')
 
         # Check custom field data from database
@@ -1037,12 +1037,12 @@ class CustomFieldModelTest(TestCase):
         site = Site(name='Test Site', slug='test-site')
 
         # Set custom field data
-        site.cf['foo'] = 'abc'
-        site.cf['bar'] = 'def'
+        site.custom_field_data['foo'] = 'abc'
+        site.custom_field_data['bar'] = 'def'
         with self.assertRaises(ValidationError):
             site.clean()
 
-        del site.cf['bar']
+        del site.custom_field_data['bar']
         site.clean()
 
     def test_missing_required_field(self):
@@ -1056,11 +1056,11 @@ class CustomFieldModelTest(TestCase):
         site = Site(name='Test Site', slug='test-site')
 
         # Set custom field data with a required field omitted
-        site.cf['foo'] = 'abc'
+        site.custom_field_data['foo'] = 'abc'
         with self.assertRaises(ValidationError):
             site.clean()
 
-        site.cf['baz'] = 'def'
+        site.custom_field_data['baz'] = 'def'
         site.clean()
 
 

+ 46 - 9
netbox/netbox/models/features.py

@@ -1,4 +1,5 @@
 from collections import defaultdict
+from functools import cached_property
 
 from django.contrib.contenttypes.fields import GenericRelation
 from django.db.models.signals import class_prepared
@@ -133,18 +134,35 @@ class CustomFieldsMixin(models.Model):
     class Meta:
         abstract = True
 
-    @property
+    @cached_property
     def cf(self):
         """
-        A pass-through convenience alias for accessing `custom_field_data` (read-only).
+        Return a dictionary mapping each custom field for this instance to its deserialized value.
 
         ```python
         >>> tenant = Tenant.objects.first()
         >>> tenant.cf
-        {'cust_id': 'CYB01'}
+        {'primary_site': <Site: DM-NYC>, 'cust_id': 'DMI01', 'is_active': True}
         ```
         """
-        return self.custom_field_data
+        return {
+            cf.name: cf.deserialize(self.custom_field_data.get(cf.name))
+            for cf in self.custom_fields
+        }
+
+    @cached_property
+    def custom_fields(self):
+        """
+        Return the QuerySet of CustomFields assigned to this model.
+
+        ```python
+        >>> tenant = Tenant.objects.first()
+        >>> tenant.custom_fields
+        <RestrictedQuerySet [<CustomField: Primary site>, <CustomField: Customer ID>, <CustomField: Is active>]>
+        ```
+        """
+        from extras.models import CustomField
+        return CustomField.objects.get_for_model(self)
 
     def get_custom_fields(self, omit_hidden=False):
         """
@@ -155,10 +173,13 @@ class CustomFieldsMixin(models.Model):
         >>> tenant.get_custom_fields()
         {<CustomField: Customer ID>: 'CYB01'}
         ```
+
+        Args:
+            omit_hidden: If True, custom fields with no UI visibility will be omitted.
         """
         from extras.models import CustomField
-
         data = {}
+
         for field in CustomField.objects.get_for_model(self):
             # Skip fields that are hidden if 'omit_hidden' is set
             if omit_hidden and field.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_HIDDEN:
@@ -172,12 +193,28 @@ class CustomFieldsMixin(models.Model):
     def get_custom_fields_by_group(self):
         """
         Return a dictionary of custom field/value mappings organized by group. Hidden fields are omitted.
+
+        ```python
+        >>> tenant = Tenant.objects.first()
+        >>> tenant.get_custom_fields_by_group()
+        {
+            '': {<CustomField: Primary site>: <Site: DM-NYC>},
+            'Billing': {<CustomField: Customer ID>: 'DMI01', <CustomField: Is active>: True}
+        }
+        ```
         """
-        grouped_custom_fields = defaultdict(dict)
-        for cf, value in self.get_custom_fields(omit_hidden=True).items():
-            grouped_custom_fields[cf.group_name][cf] = value
+        from extras.models import CustomField
+        groups = defaultdict(dict)
+        visible_custom_fields = CustomField.objects.get_for_model(self).exclude(
+            ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_HIDDEN
+        )
+
+        for cf in visible_custom_fields:
+            value = self.custom_field_data.get(cf.name)
+            value = cf.deserialize(value)
+            groups[cf.group_name][cf] = value
 
-        return dict(grouped_custom_fields)
+        return dict(groups)
 
     def clean(self):
         super().clean()

+ 1 - 1
netbox/netbox/search/__init__.py

@@ -82,7 +82,7 @@ class SearchIndex:
         # Capture custom fields
         if getattr(instance, 'custom_field_data', None):
             if custom_fields is None:
-                custom_fields = instance.get_custom_fields().keys()
+                custom_fields = instance.custom_fields
             for cf in custom_fields:
                 type_ = cf.search_type
                 value = instance.custom_field_data.get(cf.name)