Quellcode durchsuchen

fix(extras): Allow data_file assignment on export templates via API

Makes data_file writable and exposes auto_sync_enabled on the export
template serializer. Adds an opt-in Meta.model_clean_fields so values
normalized by the model's clean() survive REST creation, which the
create path previously discarded. Brings the CSV import form to parity.

Fixes #23187
Martin Hauser vor 15 Stunden
Ursprung
Commit
ca6768d206

+ 5 - 2
netbox/core/models/data.py

@@ -353,10 +353,13 @@ class DataFile(models.Model):
 
 
     def get_data(self):
     def get_data(self):
         """
         """
-        Attempt to read the file data as JSON/YAML and return a native Python object.
+        Attempt to read the file data as JSON/YAML and return a native Python object. Returns None if the file
+        content cannot be decoded.
         """
         """
         # TODO: Something more robust
         # TODO: Something more robust
-        return yaml.safe_load(self.data_as_string)
+        if (data := self.data_as_string) is None:
+            return None
+        return yaml.safe_load(data)
 
 
     def refresh_from_disk(self, source_root):
     def refresh_from_disk(self, source_root):
         """
         """

+ 6 - 2
netbox/extras/api/serializers_/configcontexts.py

@@ -28,7 +28,8 @@ class ConfigContextProfileSerializer(PrimaryModelSerializer):
     )
     )
     data_file = DataFileSerializer(
     data_file = DataFileSerializer(
         nested=True,
         nested=True,
-        required=False
+        required=False,
+        allow_null=True
     )
     )
 
 
     class Meta:
     class Meta:
@@ -38,6 +39,7 @@ class ConfigContextProfileSerializer(PrimaryModelSerializer):
             'data_source', 'data_path', 'data_file', 'data_synced', 'created', 'last_updated',
             'data_source', 'data_path', 'data_file', 'data_synced', 'created', 'last_updated',
         ]
         ]
         brief_fields = ('id', 'url', 'display', 'name', 'description')
         brief_fields = ('id', 'url', 'display', 'name', 'description')
+        model_clean_fields = ('data_source', 'data_path', 'auto_sync_enabled', 'data_synced', 'schema')
 
 
 
 
 class ConfigContextSerializer(OwnerMixin, ChangeLogMessageSerializer, ValidatedModelSerializer):
 class ConfigContextSerializer(OwnerMixin, ChangeLogMessageSerializer, ValidatedModelSerializer):
@@ -143,7 +145,8 @@ class ConfigContextSerializer(OwnerMixin, ChangeLogMessageSerializer, ValidatedM
     )
     )
     data_file = DataFileSerializer(
     data_file = DataFileSerializer(
         nested=True,
         nested=True,
-        required=False
+        required=False,
+        allow_null=True
     )
     )
 
 
     class Meta:
     class Meta:
@@ -155,3 +158,4 @@ class ConfigContextSerializer(OwnerMixin, ChangeLogMessageSerializer, ValidatedM
             'data_file', 'data_synced', 'data', 'created', 'last_updated',
             'data_file', 'data_synced', 'data', 'created', 'last_updated',
         ]
         ]
         brief_fields = ('id', 'url', 'display', 'name', 'description')
         brief_fields = ('id', 'url', 'display', 'name', 'description')
+        model_clean_fields = ('data_source', 'data_path', 'auto_sync_enabled', 'data_synced', 'data')

+ 3 - 1
netbox/extras/api/serializers_/configtemplates.py

@@ -27,7 +27,8 @@ class ConfigTemplateSerializer(
     )
     )
     data_file = DataFileSerializer(
     data_file = DataFileSerializer(
         nested=True,
         nested=True,
-        required=False
+        required=False,
+        allow_null=True
     )
     )
 
 
     class Meta:
     class Meta:
@@ -38,6 +39,7 @@ class ConfigTemplateSerializer(
             'data_file', 'auto_sync_enabled', 'data_synced', 'owner', 'tags', 'created', 'last_updated',
             'data_file', 'auto_sync_enabled', 'data_synced', 'owner', 'tags', 'created', 'last_updated',
         ]
         ]
         brief_fields = ('id', 'url', 'display', 'name', 'description')
         brief_fields = ('id', 'url', 'display', 'name', 'description')
+        model_clean_fields = ('data_source', 'data_path', 'auto_sync_enabled', 'data_synced', 'template_code')
 
 
 
 
 class RenderConfigInputSerializer(serializers.Serializer):
 class RenderConfigInputSerializer(serializers.Serializer):

+ 4 - 2
netbox/extras/api/serializers_/exporttemplates.py

@@ -21,7 +21,8 @@ class ExportTemplateSerializer(OwnerMixin, ChangeLogMessageSerializer, Validated
     )
     )
     data_file = DataFileSerializer(
     data_file = DataFileSerializer(
         nested=True,
         nested=True,
-        read_only=True
+        required=False,
+        allow_null=True
     )
     )
 
 
     class Meta:
     class Meta:
@@ -29,6 +30,7 @@ class ExportTemplateSerializer(OwnerMixin, ChangeLogMessageSerializer, Validated
         fields = [
         fields = [
             'id', 'url', 'display_url', 'display', 'object_types', 'name', 'description', 'environment_params',
             'id', 'url', 'display_url', 'display', 'object_types', 'name', 'description', 'environment_params',
             'template_code', 'mime_type', 'file_name', 'file_extension', 'as_attachment', 'data_source',
             'template_code', 'mime_type', 'file_name', 'file_extension', 'as_attachment', 'data_source',
-            'data_path', 'data_file', 'data_synced', 'owner', 'created', 'last_updated',
+            'data_path', 'data_file', 'auto_sync_enabled', 'data_synced', 'owner', 'created', 'last_updated',
         ]
         ]
         brief_fields = ('id', 'url', 'display', 'name', 'description')
         brief_fields = ('id', 'url', 'display', 'name', 'description')
+        model_clean_fields = ('data_source', 'data_path', 'auto_sync_enabled', 'data_synced', 'template_code')

+ 53 - 2
netbox/extras/forms/bulk_import.py

@@ -179,14 +179,57 @@ class ExportTemplateImportForm(OwnerCSVMixin, CSVModelForm):
         queryset=ObjectType.objects.with_feature('export_templates'),
         queryset=ObjectType.objects.with_feature('export_templates'),
         help_text=_("One or more assigned object types")
         help_text=_("One or more assigned object types")
     )
     )
+    template_code = forms.CharField(
+        label=_('Template code'),
+        required=False,
+        help_text=_('Jinja2 template code, if not sourced from a data file')
+    )
+    data_source = CSVModelChoiceField(
+        label=_('Data source'),
+        queryset=DataSource.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text=_('Data source which provides the data file')
+    )
+    data_file = CSVModelChoiceField(
+        label=_('Data file'),
+        queryset=DataFile.objects.all(),
+        required=False,
+        to_field_name='path',
+        help_text=_('Data file containing the template code')
+    )
+    auto_sync_enabled = forms.BooleanField(
+        required=False,
+        label=_('Auto sync enabled'),
+        help_text=_("Enable automatic synchronization of template content when the data file is updated")
+    )
 
 
     class Meta:
     class Meta:
         model = ExportTemplate
         model = ExportTemplate
         fields = (
         fields = (
             'name', 'object_types', 'description', 'environment_params', 'mime_type', 'file_name', 'file_extension',
             'name', 'object_types', 'description', 'environment_params', 'mime_type', 'file_name', 'file_extension',
-            'as_attachment', 'template_code', 'owner',
+            'as_attachment', 'template_code', 'data_source', 'data_file', 'auto_sync_enabled', 'owner',
         )
         )
 
 
+    def clean_data_source(self):
+        # Paths are unique only within a source, and Meta.fields cleans data_source before data_file
+        data_source = self.cleaned_data.get('data_source')
+        if data_source and 'data_file' in self.fields:
+            self.fields['data_file'].queryset = self.fields['data_file'].queryset.filter(source=data_source)
+
+        return data_source
+
+    def clean(self):
+        super().clean()
+
+        # An update record carries only the fields being changed, so fall back to the stored values
+        template_code = self.cleaned_data.get('template_code', self.instance.template_code)
+        data_file = self.cleaned_data.get('data_file', self.instance.data_file)
+        if not template_code and not data_file:
+            raise forms.ValidationError(_("Must specify either local content or a data file"))
+
+        return self.cleaned_data
+
 
 
 class ConfigContextProfileImportForm(PrimaryModelImportForm):
 class ConfigContextProfileImportForm(PrimaryModelImportForm):
 
 
@@ -226,13 +269,21 @@ class ConfigTemplateImportForm(OwnerCSVMixin, CSVModelForm):
             'tags',
             'tags',
         )
         )
 
 
+    def clean_data_source(self):
+        # Paths are unique only within a source, and Meta.fields cleans data_source before data_file
+        data_source = self.cleaned_data.get('data_source')
+        if data_source and 'data_file' in self.fields:
+            self.fields['data_file'].queryset = self.fields['data_file'].queryset.filter(source=data_source)
+
+        return data_source
+
     def clean(self):
     def clean(self):
         super().clean()
         super().clean()
 
 
         # Make sure template_code is None when it's not included in the uploaded data
         # Make sure template_code is None when it's not included in the uploaded data
         if not self.data.get('template_code') and not self.data.get('data_file'):
         if not self.data.get('template_code') and not self.data.get('data_file'):
             raise forms.ValidationError(_("Must specify either local content or a data file"))
             raise forms.ValidationError(_("Must specify either local content or a data file"))
-        return self.cleaned_data['template_code']
+        return self.cleaned_data
 
 
 
 
 class SavedFilterImportForm(OwnerCSVMixin, CSVModelForm):
 class SavedFilterImportForm(OwnerCSVMixin, CSVModelForm):

+ 3 - 3
netbox/extras/models/configs.py

@@ -70,7 +70,7 @@ class ConfigContextProfile(SyncedDataMixin, PrimaryModel):
         """
         """
         Synchronize schema from the designated DataFile (if any).
         Synchronize schema from the designated DataFile (if any).
         """
         """
-        self.schema = self.data_file.get_data()
+        self.schema = self.validate_synced_value('schema', self.data_file.get_data())
     sync_data.alters_data = True
     sync_data.alters_data = True
 
 
 
 
@@ -217,7 +217,7 @@ class ConfigContext(SyncedDataMixin, CloningMixin, CustomLinksMixin, OwnerMixin,
         """
         """
         Synchronize context data from the designated DataFile (if any).
         Synchronize context data from the designated DataFile (if any).
         """
         """
-        self.data = self.data_file.get_data()
+        self.data = self.validate_synced_value('data', self.data_file.get_data())
     sync_data.alters_data = True
     sync_data.alters_data = True
 
 
     def get_affected_objects(self, using=None):
     def get_affected_objects(self, using=None):
@@ -468,7 +468,7 @@ class ConfigTemplate(
         """
         """
         Synchronize template content from the designated DataFile (if any).
         Synchronize template content from the designated DataFile (if any).
         """
         """
-        self.template_code = self.data_file.data_as_string
+        self.template_code = self.validate_synced_value('template_code', self.data_file.data_as_string)
     sync_data.alters_data = True
     sync_data.alters_data = True
 
 
     def get_environment_params(self):
     def get_environment_params(self):

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

@@ -567,7 +567,7 @@ class ExportTemplate(
         """
         """
         Synchronize template content from the designated DataFile (if any).
         Synchronize template content from the designated DataFile (if any).
         """
         """
-        self.template_code = self.data_file.data_as_string
+        self.template_code = self.validate_synced_value('template_code', self.data_file.data_as_string)
     sync_data.alters_data = True
     sync_data.alters_data = True
 
 
     def get_context(self, context=None, queryset=None):
     def get_context(self, context=None, queryset=None):

+ 489 - 1
netbox/extras/tests/test_api.py

@@ -15,7 +15,7 @@ from rest_framework import status
 
 
 from core.choices import JobNotificationChoices, ManagedFileRootPathChoices
 from core.choices import JobNotificationChoices, ManagedFileRootPathChoices
 from core.events import *
 from core.events import *
-from core.models import DataFile, DataSource, Job, ObjectType
+from core.models import AutoSyncRecord, DataFile, DataSource, Job, ObjectType
 from dcim.models import Device, DeviceRole, DeviceType, Location, Manufacturer, Rack, RackRole, Site
 from dcim.models import Device, DeviceRole, DeviceType, Location, Manufacturer, Rack, RackRole, Site
 from extras.api.serializers import EventRuleSerializer
 from extras.api.serializers import EventRuleSerializer
 from extras.choices import *
 from extras.choices import *
@@ -967,6 +967,272 @@ class ExportTemplateTestCase(APIViewTestCases.APIViewTestCase):
         for et in export_templates:
         for et in export_templates:
             et.object_types.set([device_object_type])
             et.object_types.set([device_object_type])
 
 
+    def test_create_with_data_file(self):
+        """Creating a template from a data file persists the synced content and metadata."""
+        self.add_permissions(
+            'core.view_datafile',
+            'extras.add_exporttemplate',
+            'extras.view_exporttemplate',
+        )
+        datasource = DataSource.objects.create(
+            name='Data Source 1',
+            type='local',
+            source_url='file:///tmp/netbox-datasource/',
+        )
+        file_data = b'{% for obj in queryset %}{{ obj.name }}\n{% endfor %}'
+        datafile = DataFile.objects.create(
+            source=datasource,
+            path='exports/devices.j2',
+            last_updated=now(),
+            size=len(file_data),
+            hash=hashlib.sha256(file_data).hexdigest(),
+            data=file_data,
+        )
+        object_type = ObjectType.objects.get_for_model(ExportTemplate)
+
+        # data_source is omitted deliberately: clean() must derive it from the file
+        for auto_sync_enabled in (False, True):
+            with self.subTest(auto_sync_enabled=auto_sync_enabled):
+                payload = {
+                    'name': f'Export Template {auto_sync_enabled}',
+                    'object_types': ['dcim.device'],
+                    'template_code': '{# placeholder #}',
+                    'data_file': datafile.pk,
+                    'auto_sync_enabled': auto_sync_enabled,
+                }
+                response = self.client.post(self._get_list_url(), payload, format='json', **self.header)
+                self.assertHttpStatus(response, status.HTTP_201_CREATED)
+
+                export_template = ExportTemplate.objects.get(pk=response.data['id'])
+                self.assertEqual(export_template.data_file_id, datafile.pk)
+                self.assertEqual(export_template.data_source_id, datasource.pk)
+                self.assertEqual(export_template.data_path, datafile.path)
+                self.assertEqual(export_template.template_code, file_data.decode('utf-8'))
+                self.assertIsNotNone(export_template.data_synced)
+                self.assertEqual(export_template.auto_sync_enabled, auto_sync_enabled)
+                self.assertEqual(response.data['data_file']['id'], datafile.pk)
+                self.assertEqual(response.data['template_code'], export_template.template_code)
+                self.assertEqual(response.data['auto_sync_enabled'], auto_sync_enabled)
+                self.assertEqual(
+                    AutoSyncRecord.objects.filter(
+                        object_type=object_type,
+                        object_id=export_template.pk,
+                        datafile=datafile,
+                    ).exists(),
+                    auto_sync_enabled,
+                )
+
+    def test_create_with_unreadable_data_file(self):
+        """A data file with no readable content is rejected rather than raising a server error."""
+        self.add_permissions(
+            'core.view_datafile',
+            'extras.add_exporttemplate',
+            'extras.view_exporttemplate',
+        )
+        datasource = DataSource.objects.create(
+            name='Data Source 1',
+            type='local',
+            source_url='file:///tmp/netbox-datasource/',
+        )
+        file_data = b''
+        datafile = DataFile.objects.create(
+            source=datasource,
+            path='exports/empty.j2',
+            last_updated=now(),
+            size=len(file_data),
+            hash=hashlib.sha256(file_data).hexdigest(),
+            data=file_data,
+        )
+
+        payload = {
+            'name': 'Export Template X',
+            'object_types': ['dcim.device'],
+            'template_code': '{# placeholder #}',
+            'data_file': datafile.pk,
+        }
+        response = self.client.post(self._get_list_url(), payload, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
+        self.assertIn('data_file', response.data)
+
+    def test_update_with_data_file(self):
+        """Assigning a data file on update replaces submitted template code with the file content."""
+        self.add_permissions(
+            'core.view_datafile',
+            'extras.change_exporttemplate',
+            'extras.view_exporttemplate',
+        )
+        datasource = DataSource.objects.create(
+            name='Data Source 1',
+            type='local',
+            source_url='file:///tmp/netbox-datasource/',
+        )
+        file_data = b'{% for obj in queryset %}{{ obj.name }}\n{% endfor %}'
+        datafile = DataFile.objects.create(
+            source=datasource,
+            path='exports/devices.j2',
+            last_updated=now(),
+            size=len(file_data),
+            hash=hashlib.sha256(file_data).hexdigest(),
+            data=file_data,
+        )
+        export_template = ExportTemplate.objects.first()
+
+        payload = {
+            'data_file': datafile.pk,
+            'template_code': '{# placeholder #}',
+        }
+        response = self.client.patch(
+            self._get_detail_url(export_template), payload, format='json', **self.header
+        )
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+
+        export_template.refresh_from_db()
+        self.assertEqual(export_template.data_file_id, datafile.pk)
+        self.assertEqual(export_template.data_source_id, datasource.pk)
+        self.assertEqual(export_template.data_path, datafile.path)
+        self.assertEqual(export_template.template_code, file_data.decode('utf-8'))
+        self.assertIsNotNone(export_template.data_synced)
+
+    def test_update_with_unreadable_data_file_is_atomic(self):
+        """A rejected rebind leaves the stored content, binding and auto sync record untouched."""
+        self.add_permissions(
+            'core.view_datafile',
+            'extras.change_exporttemplate',
+            'extras.view_exporttemplate',
+        )
+        datasource = DataSource.objects.create(
+            name='Data Source 1',
+            type='local',
+            source_url='file:///tmp/netbox-datasource/',
+        )
+        file_data = b'{{ original }}'
+        datafile = DataFile.objects.create(
+            source=datasource,
+            path='exports/original.j2',
+            last_updated=now(),
+            size=len(file_data),
+            hash=hashlib.sha256(file_data).hexdigest(),
+            data=file_data,
+        )
+        empty_datafile = DataFile.objects.create(
+            source=datasource,
+            path='exports/empty.j2',
+            last_updated=now(),
+            size=0,
+            hash=hashlib.sha256(b'').hexdigest(),
+            data=b'',
+        )
+
+        export_template = ExportTemplate.objects.first()
+        export_template.data_file = datafile
+        export_template.auto_sync_enabled = True
+        export_template.clean()
+        export_template.save()
+        synced_at = ExportTemplate.objects.get(pk=export_template.pk).data_synced
+        object_type = ObjectType.objects.get_for_model(ExportTemplate)
+
+        response = self.client.patch(
+            self._get_detail_url(export_template), {'data_file': empty_datafile.pk}, format='json', **self.header
+        )
+        self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
+
+        export_template.refresh_from_db()
+        self.assertEqual(export_template.data_file_id, datafile.pk)
+        self.assertEqual(export_template.data_path, datafile.path)
+        self.assertEqual(export_template.template_code, '{{ original }}')
+        self.assertEqual(export_template.data_synced, synced_at)
+
+        autosync_record = AutoSyncRecord.objects.get(object_type=object_type, object_id=export_template.pk)
+        self.assertEqual(autosync_record.datafile_id, datafile.pk)
+
+    def test_update_rebinds_data_file(self):
+        """Rebinding to a different file moves the content, the metadata and the auto sync record."""
+        self.add_permissions(
+            'core.view_datafile',
+            'extras.change_exporttemplate',
+            'extras.view_exporttemplate',
+        )
+        datasource = DataSource.objects.create(
+            name='Data Source 1',
+            type='local',
+            source_url='file:///tmp/netbox-datasource/',
+        )
+        datafiles = []
+        for index, file_data in enumerate((b'{{ first }}', b'{{ second }}'), start=1):
+            datafiles.append(DataFile.objects.create(
+                source=datasource,
+                path=f'exports/file{index}.j2',
+                last_updated=now(),
+                size=len(file_data),
+                hash=hashlib.sha256(file_data).hexdigest(),
+                data=file_data,
+            ))
+        first_file, second_file = datafiles
+
+        export_template = ExportTemplate.objects.first()
+        export_template.data_file = first_file
+        export_template.auto_sync_enabled = True
+        export_template.clean()
+        export_template.save()
+        object_type = ObjectType.objects.get_for_model(ExportTemplate)
+
+        response = self.client.patch(
+            self._get_detail_url(export_template), {'data_file': second_file.pk}, format='json', **self.header
+        )
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+
+        export_template.refresh_from_db()
+        self.assertEqual(export_template.data_file_id, second_file.pk)
+        self.assertEqual(export_template.data_path, second_file.path)
+        self.assertEqual(export_template.template_code, '{{ second }}')
+        self.assertTrue(export_template.auto_sync_enabled)
+
+        autosync_record = AutoSyncRecord.objects.get(object_type=object_type, object_id=export_template.pk)
+        self.assertEqual(autosync_record.datafile_id, second_file.pk)
+
+    def test_update_clears_data_file(self):
+        """Clearing the data file drops the sync metadata but retains the synced content."""
+        self.add_permissions(
+            'extras.change_exporttemplate',
+            'extras.view_exporttemplate',
+        )
+        datasource = DataSource.objects.create(
+            name='Data Source 1',
+            type='local',
+            source_url='file:///tmp/netbox-datasource/',
+        )
+        file_data = b'{% for obj in queryset %}{{ obj.name }}\n{% endfor %}'
+        datafile = DataFile.objects.create(
+            source=datasource,
+            path='exports/devices.j2',
+            last_updated=now(),
+            size=len(file_data),
+            hash=hashlib.sha256(file_data).hexdigest(),
+            data=file_data,
+        )
+        export_template = ExportTemplate.objects.first()
+        export_template.data_file = datafile
+        export_template.auto_sync_enabled = True
+        export_template.clean()
+        export_template.save()
+        object_type = ObjectType.objects.get_for_model(ExportTemplate)
+
+        response = self.client.patch(
+            self._get_detail_url(export_template), {'data_file': None}, format='json', **self.header
+        )
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+
+        export_template.refresh_from_db()
+        self.assertIsNone(export_template.data_file_id)
+        self.assertIsNone(export_template.data_source_id)
+        self.assertEqual(export_template.data_path, '')
+        self.assertIsNone(export_template.data_synced)
+        self.assertFalse(export_template.auto_sync_enabled)
+        self.assertEqual(export_template.template_code, file_data.decode('utf-8'))
+        self.assertFalse(
+            AutoSyncRecord.objects.filter(object_type=object_type, object_id=export_template.pk).exists()
+        )
+
 
 
 class TagTestCase(APIViewTestCases.APIViewTestCase):
 class TagTestCase(APIViewTestCases.APIViewTestCase):
     model = Tag
     model = Tag
@@ -1215,6 +1481,71 @@ class ConfigContextProfileTestCase(APIViewTestCases.APIViewTestCase):
         )
         )
         ConfigContextProfile.objects.bulk_create(profiles)
         ConfigContextProfile.objects.bulk_create(profiles)
 
 
+    def test_create_with_invalid_schema_data_file(self):
+        """A data file holding an invalid JSON schema is rejected rather than persisted."""
+        self.add_permissions(
+            'core.view_datafile',
+            'extras.add_configcontextprofile',
+            'extras.view_configcontextprofile',
+        )
+        datasource = DataSource.objects.create(
+            name='Data Source 1',
+            type='local',
+            source_url='file:///tmp/netbox-datasource/',
+        )
+        file_data = b'type: definitely-not-a-json-type\n'
+        datafile = DataFile.objects.create(
+            source=datasource,
+            path='dir1/bad-schema.yml',
+            last_updated=now(),
+            size=len(file_data),
+            hash=hashlib.sha256(file_data).hexdigest(),
+            data=file_data,
+        )
+
+        payload = {
+            'name': 'Config Context Profile X',
+            'data_file': datafile.pk,
+        }
+        response = self.client.post(self._get_list_url(), payload, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
+        self.assertFalse(ConfigContextProfile.objects.filter(name='Config Context Profile X').exists())
+
+    def test_create_with_data_file(self):
+        """Creating a profile from a data file persists the synced schema and metadata."""
+        self.add_permissions(
+            'core.view_datafile',
+            'extras.add_configcontextprofile',
+            'extras.view_configcontextprofile',
+        )
+        datasource = DataSource.objects.create(
+            name='Data Source 1',
+            type='local',
+            source_url='file:///tmp/netbox-datasource/',
+        )
+        file_data = b'profile: configcontext\n'
+        datafile = DataFile.objects.create(
+            source=datasource,
+            path='dir1/file1.yml',
+            last_updated=now(),
+            size=len(file_data),
+            hash=hashlib.sha256(file_data).hexdigest(),
+            data=file_data,
+        )
+
+        payload = {
+            'name': 'Config Context Profile X',
+            'data_file': datafile.pk,
+        }
+        response = self.client.post(self._get_list_url(), payload, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_201_CREATED)
+
+        profile = ConfigContextProfile.objects.get(pk=response.data['id'])
+        self.assertEqual(profile.data_source_id, datasource.pk)
+        self.assertEqual(profile.data_path, datafile.path)
+        self.assertEqual(profile.schema, {'profile': 'configcontext'})
+        self.assertIsNotNone(profile.data_synced)
+
     def test_update_data_source_and_data_file(self):
     def test_update_data_source_and_data_file(self):
         """
         """
         Regression test: Ensure data_source and data_file can be assigned via the API.
         Regression test: Ensure data_source and data_file can be assigned via the API.
@@ -1292,6 +1623,127 @@ class ConfigContextTestCase(APIViewTestCases.APIViewTestCase):
         )
         )
         ConfigContext.objects.bulk_create(config_contexts)
         ConfigContext.objects.bulk_create(config_contexts)
 
 
+    def test_create_with_unserializable_data_file(self):
+        """A data file parsing to a non-JSON type is rejected rather than raising a server error."""
+        self.add_permissions(
+            'core.view_datafile',
+            'extras.add_configcontext',
+            'extras.view_configcontext',
+        )
+        datasource = DataSource.objects.create(
+            name='Data Source 1',
+            type='local',
+            source_url='file:///tmp/netbox-datasource/',
+        )
+        file_data = b'activation_date: 2026-09-18\n'
+        datafile = DataFile.objects.create(
+            source=datasource,
+            path='dir1/dates.yml',
+            last_updated=now(),
+            size=len(file_data),
+            hash=hashlib.sha256(file_data).hexdigest(),
+            data=file_data,
+        )
+
+        payload = {
+            'name': 'Config Context X',
+            'data': {'placeholder': True},
+            'data_file': datafile.pk,
+        }
+        response = self.client.post(self._get_list_url(), payload, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
+        self.assertFalse(ConfigContext.objects.filter(name='Config Context X').exists())
+
+    def test_update_with_unserializable_data_file_is_atomic(self):
+        """A rejected rebind leaves the stored data and binding untouched."""
+        self.add_permissions(
+            'core.view_datafile',
+            'extras.change_configcontext',
+            'extras.view_configcontext',
+        )
+        datasource = DataSource.objects.create(
+            name='Data Source 1',
+            type='local',
+            source_url='file:///tmp/netbox-datasource/',
+        )
+        good_data = b'foo: 123\n'
+        good_file = DataFile.objects.create(
+            source=datasource,
+            path='dir1/good.yml',
+            last_updated=now(),
+            size=len(good_data),
+            hash=hashlib.sha256(good_data).hexdigest(),
+            data=good_data,
+        )
+        bad_data = b'activation_date: 2026-09-18\n'
+        bad_file = DataFile.objects.create(
+            source=datasource,
+            path='dir1/dates.yml',
+            last_updated=now(),
+            size=len(bad_data),
+            hash=hashlib.sha256(bad_data).hexdigest(),
+            data=bad_data,
+        )
+
+        config_context = ConfigContext.objects.first()
+        config_context.data_file = good_file
+        config_context.auto_sync_enabled = True
+        config_context.clean()
+        config_context.save()
+        synced_at = ConfigContext.objects.get(pk=config_context.pk).data_synced
+        object_type = ObjectType.objects.get_for_model(ConfigContext)
+
+        response = self.client.patch(
+            self._get_detail_url(config_context), {'data_file': bad_file.pk}, format='json', **self.header
+        )
+        self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
+
+        config_context.refresh_from_db()
+        self.assertEqual(config_context.data_file_id, good_file.pk)
+        self.assertEqual(config_context.data_path, good_file.path)
+        self.assertEqual(config_context.data, {'foo': 123})
+        self.assertEqual(config_context.data_synced, synced_at)
+
+        autosync_record = AutoSyncRecord.objects.get(object_type=object_type, object_id=config_context.pk)
+        self.assertEqual(autosync_record.datafile_id, good_file.pk)
+
+    def test_create_with_data_file(self):
+        """Creating a config context from a data file persists the synced data and metadata."""
+        self.add_permissions(
+            'core.view_datafile',
+            'extras.add_configcontext',
+            'extras.view_configcontext',
+        )
+        datasource = DataSource.objects.create(
+            name='Data Source 1',
+            type='local',
+            source_url='file:///tmp/netbox-datasource/',
+        )
+        file_data = b'foo: 123\n'
+        datafile = DataFile.objects.create(
+            source=datasource,
+            path='dir1/context1.yml',
+            last_updated=now(),
+            size=len(file_data),
+            hash=hashlib.sha256(file_data).hexdigest(),
+            data=file_data,
+        )
+
+        # {} counts as blank for a required JSONField
+        payload = {
+            'name': 'Config Context X',
+            'data': {'placeholder': True},
+            'data_file': datafile.pk,
+        }
+        response = self.client.post(self._get_list_url(), payload, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_201_CREATED)
+
+        config_context = ConfigContext.objects.get(pk=response.data['id'])
+        self.assertEqual(config_context.data_source_id, datasource.pk)
+        self.assertEqual(config_context.data_path, datafile.path)
+        self.assertEqual(config_context.data, {'foo': 123})
+        self.assertIsNotNone(config_context.data_synced)
+
     def test_render_configcontext_for_object(self):
     def test_render_configcontext_for_object(self):
         """
         """
         Test rendering config context data for a device.
         Test rendering config context data for a device.
@@ -1430,6 +1882,42 @@ class ConfigTemplateTestCase(APIViewTestCases.APIViewTestCase):
         )
         )
         ConfigTemplate.objects.bulk_create(config_templates)
         ConfigTemplate.objects.bulk_create(config_templates)
 
 
+    def test_create_with_data_file(self):
+        """Creating a template from a data file persists the synced content and metadata."""
+        self.add_permissions(
+            'core.view_datafile',
+            'extras.add_configtemplate',
+            'extras.view_configtemplate',
+        )
+        datasource = DataSource.objects.create(
+            name='Data Source 1',
+            type='local',
+            source_url='file:///tmp/netbox-datasource/',
+        )
+        file_data = b'Foo: {{ foo }}'
+        datafile = DataFile.objects.create(
+            source=datasource,
+            path='configs/foo.j2',
+            last_updated=now(),
+            size=len(file_data),
+            hash=hashlib.sha256(file_data).hexdigest(),
+            data=file_data,
+        )
+
+        payload = {
+            'name': 'Config Template X',
+            'template_code': '{# placeholder #}',
+            'data_file': datafile.pk,
+        }
+        response = self.client.post(self._get_list_url(), payload, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_201_CREATED)
+
+        config_template = ConfigTemplate.objects.get(pk=response.data['id'])
+        self.assertEqual(config_template.data_source_id, datasource.pk)
+        self.assertEqual(config_template.data_path, datafile.path)
+        self.assertEqual(config_template.template_code, file_data.decode('utf-8'))
+        self.assertIsNotNone(config_template.data_synced)
+
     def test_render(self):
     def test_render(self):
         configtemplate = ConfigTemplate.objects.first()
         configtemplate = ConfigTemplate.objects.first()
 
 

+ 149 - 7
netbox/extras/tests/test_views.py

@@ -1,3 +1,4 @@
+import hashlib
 import logging
 import logging
 import uuid
 import uuid
 from unittest.mock import PropertyMock, patch
 from unittest.mock import PropertyMock, patch
@@ -7,15 +8,17 @@ from django.contrib.messages import get_messages
 from django.test import tag
 from django.test import tag
 from django.urls import reverse
 from django.urls import reverse
 from django.utils.html import escape
 from django.utils.html import escape
+from django.utils.timezone import now
 
 
 from core.choices import JobStatusChoices, ManagedFileRootPathChoices
 from core.choices import JobStatusChoices, ManagedFileRootPathChoices
 from core.events import *
 from core.events import *
-from core.models import Job, ObjectType
+from core.models import DataFile, DataSource, Job, ObjectType
 from dcim.models import DeviceType, Manufacturer, Site
 from dcim.models import DeviceType, Manufacturer, Site
 from extras.choices import *
 from extras.choices import *
 from extras.models import *
 from extras.models import *
 from extras.scripts import BooleanVar, IntegerVar, MultiChoiceVar, StringVar
 from extras.scripts import BooleanVar, IntegerVar, MultiChoiceVar, StringVar
 from extras.scripts import Script as PythonClass
 from extras.scripts import Script as PythonClass
+from netbox.choices import CSVDelimiterChoices, ImportFormatChoices
 from users.models import Group, ObjectPermission, User
 from users.models import Group, ObjectPermission, User
 from utilities.testing import TestCase, ViewTestCases
 from utilities.testing import TestCase, ViewTestCases
 
 
@@ -561,6 +564,45 @@ class ExportTemplateTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         for et in export_templates:
         for et in export_templates:
             et.object_types.set([site_type])
             et.object_types.set([site_type])
 
 
+        datasource = DataSource.objects.create(
+            name='Data Source 1',
+            type='local',
+            source_url='file:///tmp/netbox-datasource/',
+        )
+        cls.file_data = b'{% for object in queryset %}{{ object.name }}{% endfor %}'
+        cls.datafile = DataFile.objects.create(
+            source=datasource,
+            path='exports/sites.j2',
+            last_updated=now(),
+            size=len(cls.file_data),
+            hash=hashlib.sha256(cls.file_data).hexdigest(),
+            data=cls.file_data,
+        )
+
+        # The same path in two sources, so a path alone cannot identify the file
+        other_datasource = DataSource.objects.create(
+            name='Data Source 2',
+            type='local',
+            source_url='file:///tmp/netbox-datasource-2/',
+        )
+        cls.shared_path = 'exports/shared.j2'
+        cls.shared_datafile = DataFile.objects.create(
+            source=datasource,
+            path=cls.shared_path,
+            last_updated=now(),
+            size=len(cls.file_data),
+            hash=hashlib.sha256(cls.file_data).hexdigest(),
+            data=cls.file_data,
+        )
+        DataFile.objects.create(
+            source=other_datasource,
+            path=cls.shared_path,
+            last_updated=now(),
+            size=len(cls.file_data),
+            hash=hashlib.sha256(cls.file_data).hexdigest(),
+            data=cls.file_data,
+        )
+
         cls.form_data = {
         cls.form_data = {
             'name': 'Export Template X',
             'name': 'Export Template X',
             'object_types': [site_type.pk],
             'object_types': [site_type.pk],
@@ -569,12 +611,22 @@ class ExportTemplateTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             'file_name': 'template_x',
             'file_name': 'template_x',
         }
         }
 
 
-        cls.csv_data = (
-            "name,object_types,template_code,file_name",
-            f"Export Template 4,dcim.site,{TEMPLATE_CODE},",
-            f"Export Template 5,dcim.site,{TEMPLATE_CODE},template_5",
-            f"Export Template 6,dcim.site,{TEMPLATE_CODE},",
-        )
+        cls.csv_data = {
+            'default': (
+                "name,object_types,template_code,file_name",
+                f"Export Template 4,dcim.site,{TEMPLATE_CODE},",
+                f"Export Template 5,dcim.site,{TEMPLATE_CODE},template_5",
+                f"Export Template 6,dcim.site,{TEMPLATE_CODE},",
+            ),
+            'with_data_file': (
+                "name,object_types,data_file,auto_sync_enabled",
+                f"Export Template 10,dcim.site,{cls.datafile.path},true",
+            ),
+            'with_duplicate_path': (
+                "name,object_types,data_source,data_file",
+                f"Export Template 12,dcim.site,{datasource.name},{cls.shared_path}",
+            ),
+        }
 
 
         cls.csv_update_data = (
         cls.csv_update_data = (
             "id,name",
             "id,name",
@@ -589,6 +641,96 @@ class ExportTemplateTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             'as_attachment': True,
             'as_attachment': True,
         }
         }
 
 
+    def test_bulk_import_objects_with_permission(self):
+        def verify_data_file(scenario_name):
+            if scenario_name != 'with_data_file':
+                return
+            export_template = ExportTemplate.objects.get(name='Export Template 10')
+            self.assertEqual(export_template.data_file, self.datafile)
+            self.assertEqual(export_template.data_source, self.datafile.source)
+            self.assertEqual(export_template.data_path, self.datafile.path)
+            self.assertEqual(export_template.template_code, self.file_data.decode('utf-8'))
+            self.assertTrue(export_template.auto_sync_enabled)
+
+        def verify_duplicate_path(scenario_name):
+            if scenario_name != 'with_duplicate_path':
+                return
+            export_template = ExportTemplate.objects.get(name='Export Template 12')
+            self.assertEqual(export_template.data_file, self.shared_datafile)
+
+        def verify(scenario_name):
+            verify_data_file(scenario_name)
+            verify_duplicate_path(scenario_name)
+
+        super().test_bulk_import_objects_with_permission(post_import_callback=verify)
+
+    def test_bulk_import_without_content_or_data_file(self):
+        """A row supplying neither template code nor a data file is rejected."""
+        self.add_permissions('extras.add_exporttemplate')
+        initial_count = ExportTemplate.objects.count()
+
+        response = self.client.post(self._get_url('bulk_import'), {
+            'data': "name,object_types\nExport Template 11,dcim.site",
+            'format': ImportFormatChoices.CSV,
+            'csv_delimiter': CSVDelimiterChoices.AUTO,
+        })
+        self.assertHttpStatus(response, 200)
+        self.assertEqual(ExportTemplate.objects.count(), initial_count)
+
+    def test_bulk_import_with_source_id_column(self):
+        """A data_source.id column scopes the file lookup, and a malformed id is a row error."""
+        self.add_permissions('core.view_datafile', 'core.view_datasource', 'extras.add_exporttemplate')
+        source_id = self.shared_datafile.source_id
+
+        for label, value, expected_status in (
+            ('valid', str(source_id), 302),
+            ('malformed', 'not-a-number', 200),
+        ):
+            with self.subTest(source_id=label):
+                response = self.client.post(self._get_url('bulk_import'), {
+                    'data': f"name,object_types,data_source.id,data_file\n"
+                            f"Export Template {label},dcim.site,{value},{self.shared_path}",
+                    'format': ImportFormatChoices.CSV,
+                    'csv_delimiter': CSVDelimiterChoices.AUTO,
+                })
+                self.assertHttpStatus(response, expected_status)
+
+        export_template = ExportTemplate.objects.get(name='Export Template valid')
+        self.assertEqual(export_template.data_file, self.shared_datafile)
+        self.assertFalse(ExportTemplate.objects.filter(name='Export Template malformed').exists())
+
+    def test_bulk_import_update_with_source_but_no_file_column(self):
+        """An update record naming a source but no file does not trip the scoped file lookup."""
+        self.add_permissions(
+            'core.view_datafile',
+            'core.view_datasource',
+            'extras.add_exporttemplate',
+            'extras.change_exporttemplate',
+        )
+        export_template = ExportTemplate.objects.get(name='Export Template 1')
+
+        response = self.client.post(self._get_url('bulk_import'), {
+            'data': f"id,data_source\n{export_template.pk},{self.datafile.source.name}",
+            'format': ImportFormatChoices.CSV,
+            'csv_delimiter': CSVDelimiterChoices.AUTO,
+        })
+        self.assertHttpStatus(response, 302)
+
+    def test_bulk_import_update_cannot_blank_template_code(self):
+        """An update row clearing template code on a template with no data file is rejected."""
+        self.add_permissions('extras.add_exporttemplate', 'extras.change_exporttemplate')
+        export_template = ExportTemplate.objects.get(name='Export Template 1')
+
+        response = self.client.post(self._get_url('bulk_import'), {
+            'data': f"id,template_code\n{export_template.pk},",
+            'format': ImportFormatChoices.CSV,
+            'csv_delimiter': CSVDelimiterChoices.AUTO,
+        })
+        self.assertHttpStatus(response, 200)
+
+        export_template.refresh_from_db()
+        self.assertNotEqual(export_template.template_code, '')
+
     def test_content_is_not_cacheable(self):
     def test_content_is_not_cacheable(self):
         """
         """
         The detail view renders the template code inline, which may have been synced from a data
         The detail view renders the template code inline, which may have been synced from a data

+ 7 - 0
netbox/netbox/api/serializers/base.py

@@ -92,6 +92,10 @@ class ValidatedModelSerializer(BaseModelSerializer):
     """
     """
     Extends the built-in ModelSerializer to enforce calling full_clean() on a copy of the associated instance during
     Extends the built-in ModelSerializer to enforce calling full_clean() on a copy of the associated instance during
     validation. (DRF does not do this by default; see https://github.com/encode/django-rest-framework/issues/3144)
     validation. (DRF does not do this by default; see https://github.com/encode/django-rest-framework/issues/3144)
+
+    Serializers may declare `model_clean_fields` in Meta, naming scalar model fields whose clean()-normalized values
+    must reach the database. DRF builds a fresh instance from the validated data on create, discarding what clean()
+    wrote.
     """
     """
 
 
     # Bypass DRF's built-in validation of unique constraints due to DRF bug #9410. Rely instead
     # Bypass DRF's built-in validation of unique constraints due to DRF bug #9410. Rely instead
@@ -129,4 +133,7 @@ class ValidatedModelSerializer(BaseModelSerializer):
         if 'custom_field_data' in attrs:
         if 'custom_field_data' in attrs:
             data['custom_field_data'] = instance.custom_field_data
             data['custom_field_data'] = instance.custom_field_data
 
 
+        for field_name in getattr(self.Meta, 'model_clean_fields', ()):
+            data[field_name] = getattr(instance, field_name)
+
         return data
         return data

+ 20 - 0
netbox/netbox/models/features.py

@@ -675,6 +675,26 @@ class SyncedDataMixin(models.Model):
                 pass
                 pass
         return None
         return None
 
 
+    def validate_synced_value(self, field_name, value):
+        """
+        Validate content synchronized from the assigned DataFile. Model validation checks fields before clean()
+        runs, so synced values are otherwise never checked.
+        """
+        field = self._meta.get_field(field_name)
+        if value is None and not field.null:
+            raise ValidationError({
+                'data_file': _("The selected data file is empty or its content could not be read.")
+            })
+        try:
+            # Blank and null rules govern user input, so only non-empty content is put through the field
+            if value not in field.empty_values:
+                field.validate(value, self)
+            field.run_validators(value)
+        except ValidationError as e:
+            raise ValidationError({'data_file': e.messages}) from e
+
+        return value
+
     def sync(self, save=False):
     def sync(self, save=False):
         """
         """
         Synchronize the object from it's assigned DataFile (if any). This wraps sync_data() and updates
         Synchronize the object from it's assigned DataFile (if any). This wraps sync_data() and updates

+ 89 - 1
netbox/netbox/tests/test_api.py

@@ -1,3 +1,4 @@
+import hashlib
 import uuid
 import uuid
 
 
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
@@ -5,16 +6,21 @@ from django.core.exceptions import NON_FIELD_ERRORS
 from django.db.backends.postgresql.psycopg_any import NumericRange
 from django.db.backends.postgresql.psycopg_any import NumericRange
 from django.test import RequestFactory, TestCase
 from django.test import RequestFactory, TestCase
 from django.urls import reverse
 from django.urls import reverse
+from django.utils.timezone import now
 from rest_framework.exceptions import ValidationError
 from rest_framework.exceptions import ValidationError
 from rest_framework.request import Request
 from rest_framework.request import Request
 from rest_framework.settings import api_settings
 from rest_framework.settings import api_settings
 
 
+from core.models import DataFile, DataSource, ObjectType
 from dcim.api.serializers import RackSerializer
 from dcim.api.serializers import RackSerializer
 from dcim.models import Device, Site
 from dcim.models import Device, Site
-from netbox.api.exceptions import QuerySetNotOrdered
+from extras.models import ExportTemplate
+from netbox.api.exceptions import QuerySetNotOrdered, SerializerNotFound
 from netbox.api.fields import ContentTypeField, IntegerRangeSerializer, RelatedObjectCountField
 from netbox.api.fields import ContentTypeField, IntegerRangeSerializer, RelatedObjectCountField
 from netbox.api.pagination import NetBoxPagination
 from netbox.api.pagination import NetBoxPagination
+from netbox.api.serializers import ValidatedModelSerializer
 from users.models import Token
 from users.models import Token
+from utilities.api import get_serializer_for_model
 from utilities.testing import APITestCase
 from utilities.testing import APITestCase
 
 
 
 
@@ -277,3 +283,85 @@ class ContentTypeFieldTestCase(TestCase):
         self.assertEqual(field.to_internal_value(['dcim.device']), [device_ct])
         self.assertEqual(field.to_internal_value(['dcim.device']), [device_ct])
         with self.assertRaises(ValidationError):
         with self.assertRaises(ValidationError):
             field.to_internal_value(['dcim.device', 'dcim.site'])
             field.to_internal_value(['dcim.device', 'dcim.site'])
+
+
+class ValidatedModelSerializerTestCase(TestCase):
+
+    def test_serializers_declare_model_clean_fields(self):
+        """Serializers accepting a data file must declare the fields SyncedDataMixin.clean() normalizes."""
+        normalized_fields = {'data_source', 'data_path', 'auto_sync_enabled', 'data_synced'}
+
+        for object_type in ObjectType.objects.with_feature('synced_data'):
+            model = object_type.model_class()
+            if model is None:
+                continue
+            try:
+                serializer = get_serializer_for_model(model)
+            except SerializerNotFound:
+                continue
+            # Only serializers that let a client bind a data file have normalization to preserve
+            data_file = serializer().fields.get('data_file')
+            if data_file is None or data_file.read_only:
+                continue
+            with self.subTest(model=model._meta.label):
+                declared = set(getattr(serializer.Meta, 'model_clean_fields', ()))
+                self.assertTrue(
+                    normalized_fields.issubset(declared),
+                    f'{serializer.__name__}.Meta.model_clean_fields is missing '
+                    f'{sorted(normalized_fields - declared)}'
+                )
+
+    def test_model_clean_fields_is_opt_in(self):
+        """A declared field takes its value from the cleaned instance, while an undeclared one keeps the input."""
+        datasource = DataSource.objects.create(
+            name='Data Source 1',
+            type='local',
+            source_url='file:///tmp/netbox-datasource/',
+        )
+        file_data = b'{{ synced }}'
+        datafile = DataFile.objects.create(
+            source=datasource,
+            path='exports/devices.j2',
+            last_updated=now(),
+            size=len(file_data),
+            hash=hashlib.sha256(file_data).hexdigest(),
+            data=file_data,
+        )
+
+        class PlainSerializer(ValidatedModelSerializer):
+            class Meta:
+                model = ExportTemplate
+                fields = ['name', 'template_code', 'data_file']
+
+        class OptedInSerializer(PlainSerializer):
+            class Meta(PlainSerializer.Meta):
+                model_clean_fields = ('template_code',)
+
+        payload = {
+            'name': 'Export Template X',
+            'template_code': '{# placeholder #}',
+            'data_file': datafile.pk,
+        }
+
+        plain = PlainSerializer(data=payload)
+        self.assertTrue(plain.is_valid(), plain.errors)
+        self.assertEqual(plain.validated_data['template_code'], '{# placeholder #}')
+
+        opted_in = OptedInSerializer(data=payload)
+        self.assertTrue(opted_in.is_valid(), opted_in.errors)
+        self.assertEqual(opted_in.validated_data['template_code'], '{{ synced }}')
+
+    def test_nested_serializer_skips_model_validation(self):
+        """A serializer representing a nested object returns its input untouched."""
+
+        class OptedInSerializer(ValidatedModelSerializer):
+            class Meta:
+                model = ExportTemplate
+                fields = ['name', 'template_code']
+                brief_fields = ('name',)
+                model_clean_fields = ('template_code',)
+
+        serializer = OptedInSerializer(nested=True)
+        data = {'template_code': '{# untouched #}'}
+
+        self.assertEqual(serializer.validate(data), data)

+ 79 - 2
netbox/netbox/tests/test_model_features.py

@@ -1,14 +1,23 @@
+import hashlib
 from unittest import skipIf
 from unittest import skipIf
 
 
 from django.apps import apps
 from django.apps import apps
 from django.conf import settings
 from django.conf import settings
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
+from django.core.exceptions import ValidationError
 from django.test import TestCase
 from django.test import TestCase
+from django.utils.timezone import now
 from taggit.models import Tag
 from taggit.models import Tag
 
 
-from core.models import AutoSyncRecord, DataSource
+from core.models import AutoSyncRecord, DataFile, DataSource
 from dcim.models import Site
 from dcim.models import Site
-from extras.models import CustomLink
+from extras.models import (
+    ConfigContext,
+    ConfigContextProfile,
+    ConfigTemplate,
+    CustomLink,
+    ExportTemplate,
+)
 from ipam.models import Prefix
 from ipam.models import Prefix
 from netbox.constants import CORE_APPS
 from netbox.constants import CORE_APPS
 from netbox.models.features import CloningMixin, get_model_features, has_feature, model_is_public
 from netbox.models.features import CloningMixin, get_model_features, has_feature, model_is_public
@@ -82,6 +91,74 @@ class ModelFeaturesTestCase(TestCase):
         )
         )
         self.assertEqual(offenders, [], "clone_fields is inert on models which do not inherit CloningMixin")
         self.assertEqual(offenders, [], "clone_fields is inert on models which do not inherit CloningMixin")
 
 
+    def _create_data_file(self, path, file_data):
+        source, _ = DataSource.objects.get_or_create(
+            name='Data Source 1',
+            defaults={'type': 'local', 'source_url': 'file:///tmp/netbox-datasource/'},
+        )
+        return DataFile.objects.create(
+            source=source,
+            path=path,
+            last_updated=now(),
+            size=len(file_data),
+            hash=hashlib.sha256(file_data).hexdigest(),
+            data=file_data,
+        )
+
+    def test_synceddatamixin_rejects_unreadable_file(self):
+        """An empty or undecodable data file raises a validation error rather than reaching the database."""
+        models = (ConfigContext, ConfigTemplate, ExportTemplate)
+
+        for label, file_data in (('empty', b''), ('non-utf8', b'\xff\xfe\x00\x01')):
+            datafile = self._create_data_file(f'{label}.j2', file_data)
+            for model in models:
+                with self.subTest(content=label, model=model.__name__):
+                    with self.assertRaises(ValidationError):
+                        model(name='Test', data_file=datafile).clean()
+
+    def test_synceddatamixin_rejects_file_holding_no_document(self):
+        """A data file parsing to no document is rejected only where the target field cannot store null."""
+        datafile = self._create_data_file('comment-only.yml', b'# nothing here\n')
+
+        with self.assertRaises(ValidationError):
+            ConfigContext(name='Test', data_file=datafile).clean()
+
+        profile = ConfigContextProfile(name='Test', data_file=datafile)
+        profile.clean()
+        profile.save()
+        self.assertIsNone(profile.schema)
+
+    def test_synceddatamixin_rejects_unserializable_document(self):
+        """A parsed document holding a non-JSON type is rejected before it reaches the database."""
+        datafile = self._create_data_file('dates.yml', b'activation_date: 2026-09-18\n')
+
+        for model in (ConfigContext, ConfigContextProfile):
+            with self.subTest(model=model.__name__):
+                with self.assertRaises(ValidationError):
+                    model(name='Test', data_file=datafile).clean()
+
+    def test_synceddatamixin_synchronizes_empty_object(self):
+        """An empty object is valid synced content, so it must keep synchronizing and persisting."""
+        datafile = self._create_data_file('empty-object.yml', b'{}\n')
+
+        for model, field_name in ((ConfigContext, 'data'), (ConfigContextProfile, 'schema')):
+            with self.subTest(model=model.__name__):
+                obj = model(name='Test', data_file=datafile)
+                obj.clean()
+                obj.save()
+                obj.refresh_from_db()
+                self.assertEqual(getattr(obj, field_name), {})
+
+    def test_synceddatamixin_accepts_plain_text_template(self):
+        """Text which is not a YAML document is still valid template content."""
+        datafile = self._create_data_file('comment-only.j2', b'{# nothing here #}\n')
+
+        for model in (ConfigTemplate, ExportTemplate):
+            with self.subTest(model=model.__name__):
+                obj = model(name='Test', data_file=datafile)
+                obj.clean()
+                self.assertEqual(obj.template_code, '{# nothing here #}\n')
+
     def test_cloningmixin_emits_gfk_subwidget_params(self):
     def test_cloningmixin_emits_gfk_subwidget_params(self):
         """A cloned GFK is exposed as the GenericObjectChoiceField subwidget params."""
         """A cloned GFK is exposed as the GenericObjectChoiceField subwidget params."""
         site = Site.objects.create(name='Test Site', slug='test-site')
         site = Site.objects.create(name='Test Site', slug='test-site')