소스 검색

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 5 시간 전
부모
커밋
c3672d6357

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

@@ -353,10 +353,13 @@ class DataFile(models.Model):
 
     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
-        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):
         """

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

@@ -28,7 +28,8 @@ class ConfigContextProfileSerializer(PrimaryModelSerializer):
     )
     data_file = DataFileSerializer(
         nested=True,
-        required=False
+        required=False,
+        allow_null=True
     )
 
     class Meta:
@@ -38,6 +39,7 @@ class ConfigContextProfileSerializer(PrimaryModelSerializer):
             'data_source', 'data_path', 'data_file', 'data_synced', 'created', 'last_updated',
         ]
         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):
@@ -143,7 +145,8 @@ class ConfigContextSerializer(OwnerMixin, ChangeLogMessageSerializer, ValidatedM
     )
     data_file = DataFileSerializer(
         nested=True,
-        required=False
+        required=False,
+        allow_null=True
     )
 
     class Meta:
@@ -155,3 +158,4 @@ class ConfigContextSerializer(OwnerMixin, ChangeLogMessageSerializer, ValidatedM
             'data_file', 'data_synced', 'data', 'created', 'last_updated',
         ]
         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(
         nested=True,
-        required=False
+        required=False,
+        allow_null=True
     )
 
     class Meta:
@@ -38,6 +39,7 @@ class ConfigTemplateSerializer(
             'data_file', 'auto_sync_enabled', 'data_synced', 'owner', 'tags', 'created', 'last_updated',
         ]
         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):

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

@@ -21,7 +21,8 @@ class ExportTemplateSerializer(OwnerMixin, ChangeLogMessageSerializer, Validated
     )
     data_file = DataFileSerializer(
         nested=True,
-        read_only=True
+        required=False,
+        allow_null=True
     )
 
     class Meta:
@@ -29,6 +30,7 @@ class ExportTemplateSerializer(OwnerMixin, ChangeLogMessageSerializer, Validated
         fields = [
             'id', 'url', 'display_url', 'display', 'object_types', 'name', 'description', 'environment_params',
             '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')
+        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'),
         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:
         model = ExportTemplate
         fields = (
             '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 __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        # A path identifies a file only within its source, so narrow the lookup when a source is given
+        if data_source := self.data.get('data_source'):
+            lookup = self.fields['data_source'].to_field_name or 'pk'
+            self.fields['data_file'].queryset = DataFile.objects.filter(**{f'source__{lookup}': 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):
 
@@ -226,13 +269,21 @@ class ConfigTemplateImportForm(OwnerCSVMixin, CSVModelForm):
             'tags',
         )
 
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        # A path identifies a file only within its source, so narrow the lookup when a source is given
+        if data_source := self.data.get('data_source'):
+            lookup = self.fields['data_source'].to_field_name or 'pk'
+            self.fields['data_file'].queryset = DataFile.objects.filter(**{f'source__{lookup}': data_source})
+
     def clean(self):
         super().clean()
 
         # 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'):
             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):

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

@@ -70,7 +70,7 @@ class ConfigContextProfile(SyncedDataMixin, PrimaryModel):
         """
         Synchronize schema from the designated DataFile (if any).
         """
-        self.schema = self.data_file.get_data()
+        self.schema = self.validate_synced_value(self.data_file.get_data())
     sync_data.alters_data = True
 
 
@@ -217,7 +217,7 @@ class ConfigContext(SyncedDataMixin, CloningMixin, CustomLinksMixin, OwnerMixin,
         """
         Synchronize context data from the designated DataFile (if any).
         """
-        self.data = self.data_file.get_data()
+        self.data = self.validate_synced_value(self.data_file.get_data())
     sync_data.alters_data = True
 
     def get_affected_objects(self, using=None):
@@ -468,7 +468,7 @@ class ConfigTemplate(
         """
         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(self.data_file.data_as_string)
     sync_data.alters_data = True
 
     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).
         """
-        self.template_code = self.data_file.data_as_string
+        self.template_code = self.validate_synced_value(self.data_file.data_as_string)
     sync_data.alters_data = True
 
     def get_context(self, context=None, queryset=None):

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

@@ -15,7 +15,7 @@ from rest_framework import status
 
 from core.choices import JobNotificationChoices, ManagedFileRootPathChoices
 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 extras.api.serializers import EventRuleSerializer
 from extras.choices import *
@@ -967,6 +967,220 @@ class ExportTemplateTestCase(APIViewTestCases.APIViewTestCase):
         for et in export_templates:
             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_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):
     model = Tag
@@ -1215,6 +1429,41 @@ class ConfigContextProfileTestCase(APIViewTestCases.APIViewTestCase):
         )
         ConfigContextProfile.objects.bulk_create(profiles)
 
+    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):
         """
         Regression test: Ensure data_source and data_file can be assigned via the API.
@@ -1292,6 +1541,43 @@ class ConfigContextTestCase(APIViewTestCases.APIViewTestCase):
         )
         ConfigContext.objects.bulk_create(config_contexts)
 
+    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):
         """
         Test rendering config context data for a device.
@@ -1430,6 +1716,42 @@ class ConfigTemplateTestCase(APIViewTestCases.APIViewTestCase):
         )
         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):
         configtemplate = ConfigTemplate.objects.first()
 

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

@@ -1,3 +1,4 @@
+import hashlib
 import logging
 import uuid
 from unittest.mock import PropertyMock, patch
@@ -7,15 +8,17 @@ from django.contrib.messages import get_messages
 from django.test import tag
 from django.urls import reverse
 from django.utils.html import escape
+from django.utils.timezone import now
 
 from core.choices import JobStatusChoices, ManagedFileRootPathChoices
 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 extras.choices import *
 from extras.models import *
 from extras.scripts import BooleanVar, IntegerVar, MultiChoiceVar, StringVar
 from extras.scripts import Script as PythonClass
+from netbox.choices import CSVDelimiterChoices, ImportFormatChoices
 from users.models import Group, ObjectPermission, User
 from utilities.testing import TestCase, ViewTestCases
 
@@ -561,6 +564,45 @@ class ExportTemplateTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         for et in export_templates:
             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 = {
             'name': 'Export Template X',
             'object_types': [site_type.pk],
@@ -569,12 +611,22 @@ class ExportTemplateTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             '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 = (
             "id,name",
@@ -589,6 +641,57 @@ class ExportTemplateTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             '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_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):
         """
         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
     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
@@ -129,4 +133,7 @@ class ValidatedModelSerializer(BaseModelSerializer):
         if 'custom_field_data' in attrs:
             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

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

@@ -675,6 +675,17 @@ class SyncedDataMixin(models.Model):
                 pass
         return None
 
+    def validate_synced_value(self, value):
+        """
+        Guard the value read from the assigned DataFile. Models call this from sync_data() so that unusable file
+        content surfaces as a validation error instead of failing later against the database.
+        """
+        if value is None:
+            raise ValidationError({
+                'data_file': _("The selected data file is empty or its content could not be read.")
+            })
+        return value
+
     def sync(self, save=False):
         """
         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
 
 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.test import RequestFactory, TestCase
 from django.urls import reverse
+from django.utils.timezone import now
 from rest_framework.exceptions import ValidationError
 from rest_framework.request import Request
 from rest_framework.settings import api_settings
 
+from core.models import DataFile, DataSource, ObjectType
 from dcim.api.serializers import RackSerializer
 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.pagination import NetBoxPagination
+from netbox.api.serializers import ValidatedModelSerializer
 from users.models import Token
+from utilities.api import get_serializer_for_model
 from utilities.testing import APITestCase
 
 
@@ -277,3 +283,85 @@ class ContentTypeFieldTestCase(TestCase):
         self.assertEqual(field.to_internal_value(['dcim.device']), [device_ct])
         with self.assertRaises(ValidationError):
             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)

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

@@ -1,14 +1,23 @@
+import hashlib
 from unittest import skipIf
 
 from django.apps import apps
 from django.conf import settings
 from django.contrib.contenttypes.models import ContentType
+from django.core.exceptions import ValidationError
 from django.test import TestCase
+from django.utils.timezone import now
 from taggit.models import Tag
 
-from core.models import AutoSyncRecord, DataSource
+from core.models import AutoSyncRecord, DataFile, DataSource
 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 netbox.constants import CORE_APPS
 from netbox.models.features import CloningMixin, get_model_features, has_feature, model_is_public
@@ -82,6 +91,50 @@ class ModelFeaturesTestCase(TestCase):
         )
         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, ConfigContextProfile, 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 raises a validation error for models storing parsed data."""
+        datafile = self._create_data_file('comment-only.yml', b'# nothing here\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_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):
         """A cloned GFK is exposed as the GenericObjectChoiceField subwidget params."""
         site = Site.objects.create(name='Test Site', slug='test-site')