Repository: netbox-community/netbox

Issue:
Custom field defaults are not set on templated device/VM components upon creation
### NetBox version

v3.5.9

### Python version

3.10

### Steps to Reproduce

* Create a custom field which is required AND has a default value. In this example we have a boolean field which is required and has a default value of `False`:

![grafik](https://github.com/netbox-community/netbox/assets/1025875/ed7a7a8d-3a4a-4282-94d0-3af2c2172dbe)

* Now I create a new device in NetBox based off of an existing device template. We can see it has a bunch of interfaces already defined:

![grafik](https://github.com/netbox-community/netbox/assets/1025875/95943324-d52b-488f-a12c-ec57bd62c0b6)

* If I examine the interfaces, they all have no value set for this custom field, even though a default is defined:

![grafik](https://github.com/netbox-community/netbox/assets/1025875/fe0a6145-d1fc-4568-b7cf-7b9987cded61)

* Each time we create a device, we have to set this value for all the interfaces, every though a default has been defined. This is why I think this is a bug, the default value is not being set.


### Expected Behavior

All these interfaces should have this custom field set to `False` by default as per the custom field definition.

### Observed Behavior

All instances of this custom field have no value set which I think is unexpected when you consider that this is a required boolean field, and neither `True` nor `False` is set.


Discussion:

Resolving patch:
diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py
--- a/netbox/dcim/models/devices.py
+++ b/netbox/dcim/models/devices.py
@@ -16,7 +16,7 @@
 
 from dcim.choices import *
 from dcim.constants import *
-from extras.models import ConfigContextModel
+from extras.models import ConfigContextModel, CustomField
 from extras.querysets import ConfigContextModelQuerySet
 from netbox.config import ConfigItem
 from netbox.models import OrganizationalModel, PrimaryModel
@@ -985,11 +985,17 @@ def _instantiate_components(self, queryset, bulk_create=True):
             bulk_create: If True, bulk_create() will be called to create all components in a single query
                          (default). Otherwise, save() will be called on each instance individually.
         """
+        components = [obj.instantiate(device=self) for obj in queryset]
+        if not components:
+            return
+
+        # Set default values for any applicable custom fields
+        model = queryset.model.component_model
+        if cf_defaults := CustomField.objects.get_defaults_for_model(model):
+            for component in components:
+                component.custom_field_data = cf_defaults
+
         if bulk_create:
-            components = [obj.instantiate(device=self) for obj in queryset]
-            if not components:
-                return
-            model = components[0]._meta.model
             model.objects.bulk_create(components)
             # Manually send the post_save signal for each of the newly created components
             for component in components:
@@ -1002,8 +1008,7 @@ def _instantiate_components(self, queryset, bulk_create=True):
                     update_fields=None
                 )
         else:
-            for obj in queryset:
-                component = obj.instantiate(device=self)
+            for component in components:
                 component.save()
 
     def save(self, *args, **kwargs):
diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py
--- a/netbox/extras/models/customfields.py
+++ b/netbox/extras/models/customfields.py
@@ -57,6 +57,15 @@ def get_for_model(self, model):
         content_type = ContentType.objects.get_for_model(model._meta.concrete_model)
         return self.get_queryset().filter(content_types=content_type)
 
+    def get_defaults_for_model(self, model):
+        """
+        Return a dictionary of serialized default values for all CustomFields applicable to the given model.
+        """
+        custom_fields = self.get_for_model(model).filter(default__isnull=False)
+        return {
+            cf.name: cf.default for cf in custom_fields
+        }
+
 
 class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
     content_types = models.ManyToManyField(


Test patch:
diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py
--- a/netbox/dcim/tests/test_models.py
+++ b/netbox/dcim/tests/test_models.py
@@ -1,9 +1,11 @@
+from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ValidationError
 from django.test import TestCase
 
 from circuits.models import *
 from dcim.choices import *
 from dcim.models import *
+from extras.models import CustomField
 from tenancy.models import Tenant
 from utilities.utils import drange
 
@@ -255,6 +257,23 @@ def setUpTestData(cls):
         )
         DeviceRole.objects.bulk_create(roles)
 
+        # Create a CustomField with a default value & assign it to all component models
+        cf1 = CustomField.objects.create(name='cf1', default='foo')
+        cf1.content_types.set(
+            ContentType.objects.filter(app_label='dcim', model__in=[
+                'consoleport',
+                'consoleserverport',
+                'powerport',
+                'poweroutlet',
+                'interface',
+                'rearport',
+                'frontport',
+                'modulebay',
+                'devicebay',
+                'inventoryitem',
+            ])
+        )
+
         # Create DeviceType components
         ConsolePortTemplate(
             device_type=device_type,
@@ -266,18 +285,18 @@ def setUpTestData(cls):
             name='Console Server Port 1'
         ).save()
 
-        ppt = PowerPortTemplate(
+        powerport = PowerPortTemplate(
             device_type=device_type,
             name='Power Port 1',
             maximum_draw=1000,
             allocated_draw=500
         )
-        ppt.save()
+        powerport.save()
 
         PowerOutletTemplate(
             device_type=device_type,
             name='Power Outlet 1',
-            power_port=ppt,
+            power_port=powerport,
             feed_leg=PowerOutletFeedLegChoices.FEED_LEG_A
         ).save()
 
@@ -288,19 +307,19 @@ def setUpTestData(cls):
             mgmt_only=True
         ).save()
 
-        rpt = RearPortTemplate(
+        rearport = RearPortTemplate(
             device_type=device_type,
             name='Rear Port 1',
             type=PortTypeChoices.TYPE_8P8C,
             positions=8
         )
-        rpt.save()
+        rearport.save()
 
         FrontPortTemplate(
             device_type=device_type,
             name='Front Port 1',
             type=PortTypeChoices.TYPE_8P8C,
-            rear_port=rpt,
+            rear_port=rearport,
             rear_port_position=2
         ).save()
 
@@ -314,73 +333,93 @@ def setUpTestData(cls):
             name='Device Bay 1'
         ).save()
 
+        InventoryItemTemplate(
+            device_type=device_type,
+            name='Inventory Item 1'
+        ).save()
+
     def test_device_creation(self):
         """
         Ensure that all Device components are copied automatically from the DeviceType.
         """
-        d = Device(
+        device = Device(
             site=Site.objects.first(),
             device_type=DeviceType.objects.first(),
             role=DeviceRole.objects.first(),
             name='Test Device 1'
         )
-        d.save()
+        device.save()
 
-        ConsolePort.objects.get(
-            device=d,
+        consoleport = ConsolePort.objects.get(
+            device=device,
             name='Console Port 1'
         )
+        self.assertEqual(consoleport.cf['cf1'], 'foo')
 
-        ConsoleServerPort.objects.get(
-            device=d,
+        consoleserverport = ConsoleServerPort.objects.get(
+            device=device,
             name='Console Server Port 1'
         )
+        self.assertEqual(consoleserverport.cf['cf1'], 'foo')
 
-        pp = PowerPort.objects.get(
-            device=d,
+        powerport = PowerPort.objects.get(
+            device=device,
             name='Power Port 1',
             maximum_draw=1000,
             allocated_draw=500
         )
+        self.assertEqual(powerport.cf['cf1'], 'foo')
 
-        PowerOutlet.objects.get(
-            device=d,
+        poweroutlet = PowerOutlet.objects.get(
+            device=device,
             name='Power Outlet 1',
-            power_port=pp,
+            power_port=powerport,
             feed_leg=PowerOutletFeedLegChoices.FEED_LEG_A
         )
+        self.assertEqual(poweroutlet.cf['cf1'], 'foo')
 
-        Interface.objects.get(
-            device=d,
+        interface = Interface.objects.get(
+            device=device,
             name='Interface 1',
             type=InterfaceTypeChoices.TYPE_1GE_FIXED,
             mgmt_only=True
         )
+        self.assertEqual(interface.cf['cf1'], 'foo')
 
-        rp = RearPort.objects.get(
-            device=d,
+        rearport = RearPort.objects.get(
+            device=device,
             name='Rear Port 1',
             type=PortTypeChoices.TYPE_8P8C,
             positions=8
         )
+        self.assertEqual(rearport.cf['cf1'], 'foo')
 
-        FrontPort.objects.get(
-            device=d,
+        frontport = FrontPort.objects.get(
+            device=device,
             name='Front Port 1',
             type=PortTypeChoices.TYPE_8P8C,
-            rear_port=rp,
+            rear_port=rearport,
             rear_port_position=2
         )
+        self.assertEqual(frontport.cf['cf1'], 'foo')
 
-        ModuleBay.objects.get(
-            device=d,
+        modulebay = ModuleBay.objects.get(
+            device=device,
             name='Module Bay 1'
         )
+        self.assertEqual(modulebay.cf['cf1'], 'foo')
 
-        DeviceBay.objects.get(
-            device=d,
+        devicebay = DeviceBay.objects.get(
+            device=device,
             name='Device Bay 1'
         )
+        self.assertEqual(devicebay.cf['cf1'], 'foo')
+
+        inventoryitem = InventoryItem.objects.get(
+            device=device,
+            name='Inventory Item 1'
+        )
+        self.assertEqual(inventoryitem.cf['cf1'], 'foo')
 
     def test_multiple_unnamed_devices(self):