diff --git a/docs/release-notes/version-3.4.md b/docs/release-notes/version-3.4.md index 69009ea8d..ce3edc07c 100644 --- a/docs/release-notes/version-3.4.md +++ b/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 diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index c6ba96a82..7e7eaeda0 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/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() diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index f59e72c14..8e5af0ab5 100644 --- a/netbox/netbox/models/features.py +++ b/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': , '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 + , , ]> + ``` + """ + 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() {: '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. - """ - 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 - return dict(grouped_custom_fields) + ```python + >>> tenant = Tenant.objects.first() + >>> tenant.get_custom_fields_by_group() + { + '': {: }, + 'Billing': {: 'DMI01', : True} + } + ``` + """ + 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(groups) def clean(self): super().clean() diff --git a/netbox/netbox/search/__init__.py b/netbox/netbox/search/__init__.py index c05a2492b..82fff68c6 100644 --- a/netbox/netbox/search/__init__.py +++ b/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)