Browse Source

Merge branch 'main' into 21879-update-dependent

Arthur 2 days ago
parent
commit
0219bd7aa3

+ 2 - 0
.github/workflows/close-incomplete-issues.yml

@@ -23,6 +23,8 @@ jobs:
             to include all the requested detail, and then ask that the issue be reopened.
           days-before-stale: 7
           days-before-close: 7
+          days-before-pr-stale: -1
+          days-before-pr-close: -1
           only-issue-labels: 'status: revisions needed'
           operations-per-run: 100
           remove-stale-when-updated: false

+ 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 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):
 
@@ -226,13 +269,21 @@ class ConfigTemplateImportForm(OwnerCSVMixin, CSVModelForm):
             '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):
         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('schema', 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('data', 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('template_code', 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('template_code', self.data_file.data_as_string)
     sync_data.alters_data = True
 
     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.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,272 @@ 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_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):
     model = Tag
@@ -1215,6 +1481,71 @@ class ConfigContextProfileTestCase(APIViewTestCases.APIViewTestCase):
         )
         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):
         """
         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)
 
+    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):
         """
         Test rendering config context data for a device.
@@ -1430,6 +1882,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()
 

+ 149 - 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,96 @@ 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_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):
         """
         The detail view renders the template code inline, which may have been synced from a data

+ 66 - 0
netbox/ipam/tests/test_views.py

@@ -2077,6 +2077,72 @@ class IPAddressTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         for objectchange in objectchanges:
             self.assertEqual(objectchange.message, changelog_message)
 
+    def test_ipaddress_assign_return_url(self):
+        """The Cancel link keeps a safe return_url and falls back to the list for unsafe ones."""
+        self.add_permissions('ipam.view_ipaddress')
+        url = self._get_url('assign')
+        fallback = reverse('ipam:ipaddress_list')
+        safe_url = '/dcim/interfaces/1/?tab=main#ipaddresses'
+
+        # No return_url at all falls back to the list.
+        response = self.client.get(url, data={'interface': 1})
+        self.assertHttpStatus(response, 200)
+        self.assertEqual(response.context['return_url'], fallback)
+
+        cases = (
+            (safe_url, safe_url),
+            ('', fallback),
+            ('javascript:void(0)', fallback),
+            ('data:text/html,test', fallback),
+            ('https://example.invalid/', fallback),
+            ('//example.invalid/', fallback),
+            # safe_for_redirect() passes allowed_hosts=None, so a URL with any netloc is
+            # rejected even when the host is the one serving the request.
+            ('http://testserver/ipam/ip-addresses/', fallback),
+        )
+        for return_url, expected in cases:
+            with self.subTest(return_url=return_url):
+                response = self.client.get(url, data={'interface': 1, 'return_url': return_url})
+                self.assertHttpStatus(response, 200)
+                self.assertEqual(response.context['return_url'], expected)
+                self.assertContains(response, f'<a href="{expected}" class="btn btn-outline-secondary">')
+
+    def test_ipaddress_assign_search_return_url(self):
+        """A search POST renders a safe Cancel link when the query string carries an unsafe return_url."""
+        self.add_permissions('ipam.view_ipaddress')
+        fallback = reverse('ipam:ipaddress_list')
+        # The payload stays in the query string: post() reads request.GET, as the form action does.
+        url = f"{self._get_url('assign')}?interface=1&return_url=javascript:alert(document.cookie)"
+        cancel_link = f'<a href="{fallback}" class="btn btn-outline-secondary">'
+
+        # A valid search renders the results table.
+        response = self.client.post(url, data={'q': '192.0.2.1'})
+        self.assertHttpStatus(response, 200)
+        self.assertEqual(response.context['return_url'], fallback)
+        self.assertContains(response, cancel_link)
+        self.assertIsNotNone(response.context['table'])
+
+        # An invalid search redisplays the form with no table.
+        response = self.client.post(url, data={'vrf_id': '99999'})
+        self.assertHttpStatus(response, 200)
+        self.assertEqual(response.context['return_url'], fallback)
+        self.assertContains(response, cancel_link)
+        self.assertIsNone(response.context['table'])
+
+    def test_ipaddress_assign_return_url_from_post_data(self):
+        """Absent a query-string value, the Cancel link honors a safe POST return_url and rejects an unsafe one."""
+        self.add_permissions('ipam.view_ipaddress')
+        url = f"{self._get_url('assign')}?vminterface=1"
+        fallback = reverse('ipam:ipaddress_list')
+        safe_url = '/virtualization/virtual-machines/1/interfaces/'
+
+        for return_url, expected in ((safe_url, safe_url), ('javascript:void(0)', fallback)):
+            with self.subTest(return_url=return_url):
+                response = self.client.post(url, data={'q': '192.0.2.1', 'return_url': return_url})
+                self.assertHttpStatus(response, 200)
+                self.assertEqual(response.context['return_url'], expected)
+                self.assertContains(response, f'<a href="{expected}" class="btn btn-outline-secondary">')
+
 
 class FHRPGroupTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = FHRPGroup

+ 4 - 4
netbox/ipam/views.py

@@ -26,7 +26,7 @@ from netbox.ui.panels import (
 from netbox.views import generic
 from utilities.query import count_related
 from utilities.tables import get_table_ordering
-from utilities.views import GetRelatedModelsMixin, ViewTab, register_model_view
+from utilities.views import GetRelatedModelsMixin, GetReturnURLMixin, ViewTab, register_model_view
 from virtualization.filtersets import VMInterfaceFilterSet
 from virtualization.forms import VMInterfaceFilterForm
 from virtualization.models import VirtualMachine, VMInterface
@@ -1234,7 +1234,7 @@ class IPAddressEditView(generic.ObjectEditView):
 
 # TODO: Standardize or remove this view
 @register_model_view(IPAddress, 'assign', path='assign', detail=False)
-class IPAddressAssignView(generic.ObjectView):
+class IPAddressAssignView(GetReturnURLMixin, generic.ObjectView):
     """
     Search for IPAddresses to be assigned to an Interface.
     """
@@ -1253,7 +1253,7 @@ class IPAddressAssignView(generic.ObjectView):
 
         return render(request, 'ipam/ipaddress_assign.html', {
             'form': form,
-            'return_url': request.GET.get('return_url', ''),
+            'return_url': self.get_return_url(request),
         })
 
     def post(self, request):
@@ -1270,7 +1270,7 @@ class IPAddressAssignView(generic.ObjectView):
         return render(request, 'ipam/ipaddress_assign.html', {
             'form': form,
             'table': table,
-            'return_url': request.GET.get('return_url'),
+            'return_url': self.get_return_url(request),
         })
 
 

+ 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

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

@@ -675,6 +675,26 @@ class SyncedDataMixin(models.Model):
                 pass
         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):
         """
         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)

+ 79 - 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,74 @@ 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, 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):
         """A cloned GFK is exposed as the GenericObjectChoiceField subwidget params."""
         site = Site.objects.create(name='Test Site', slug='test-site')

+ 57 - 42
netbox/translations/en/LC_MESSAGES/django.po

@@ -8,7 +8,7 @@ msgid ""
 msgstr ""
 "Project-Id-Version: PACKAGE VERSION\n"
 "Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2026-09-18 05:01+0000\n"
+"POT-Creation-Date: 2026-09-22 05:02+0000\n"
 "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
 "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
 "Language-Team: LANGUAGE <LL@li.org>\n"
@@ -2339,7 +2339,8 @@ msgid "File"
 msgstr ""
 
 #: netbox/core/forms/filtersets.py:70 netbox/core/forms/mixins.py:16
-#: netbox/extras/forms/bulk_import.py:202 netbox/extras/forms/filtersets.py:206
+#: netbox/extras/forms/bulk_import.py:188
+#: netbox/extras/forms/bulk_import.py:245 netbox/extras/forms/filtersets.py:206
 #: netbox/extras/forms/filtersets.py:429 netbox/extras/forms/filtersets.py:461
 #: netbox/extras/forms/filtersets.py:549
 msgid "Data source"
@@ -2748,11 +2749,11 @@ msgstr ""
 msgid "data files"
 msgstr ""
 
-#: netbox/core/models/data.py:410
+#: netbox/core/models/data.py:413
 msgid "auto sync record"
 msgstr ""
 
-#: netbox/core/models/data.py:411
+#: netbox/core/models/data.py:414
 msgid "auto sync records"
 msgstr ""
 
@@ -6414,7 +6415,7 @@ msgid "Occupied"
 msgstr ""
 
 #: netbox/dcim/forms/filtersets.py:2033 netbox/extras/forms/bulk_edit.py:449
-#: netbox/extras/forms/bulk_import.py:370 netbox/extras/forms/filtersets.py:633
+#: netbox/extras/forms/bulk_import.py:421 netbox/extras/forms/filtersets.py:633
 #: netbox/extras/forms/model_forms.py:922 netbox/extras/tables/tables.py:790
 msgid "Kind"
 msgstr ""
@@ -9756,13 +9757,13 @@ msgstr ""
 msgid "No config template found for this {object_type}."
 msgstr ""
 
-#: netbox/extras/api/serializers_/configtemplates.py:51
+#: netbox/extras/api/serializers_/configtemplates.py:53
 msgid ""
 "Optional ID of the ConfigTemplate to render. If omitted, the object's "
 "assigned config template is used."
 msgstr ""
 
-#: netbox/extras/api/serializers_/configtemplates.py:77
+#: netbox/extras/api/serializers_/configtemplates.py:79
 msgid "The rendered template output."
 msgstr ""
 
@@ -10493,7 +10494,7 @@ msgstr ""
 msgid "Timeout"
 msgstr ""
 
-#: netbox/extras/forms/bulk_edit.py:307 netbox/extras/forms/bulk_import.py:270
+#: netbox/extras/forms/bulk_edit.py:307 netbox/extras/forms/bulk_import.py:321
 #: netbox/extras/forms/model_forms.py:611
 msgid "Event types"
 msgstr ""
@@ -10510,16 +10511,16 @@ msgstr ""
 msgid "Is active"
 msgstr ""
 
-#: netbox/extras/forms/bulk_edit.py:424 netbox/extras/forms/bulk_import.py:217
-#: netbox/extras/forms/filtersets.py:560
+#: netbox/extras/forms/bulk_edit.py:424 netbox/extras/forms/bulk_import.py:203
+#: netbox/extras/forms/bulk_import.py:260 netbox/extras/forms/filtersets.py:560
 msgid "Auto sync enabled"
 msgstr ""
 
 #: netbox/extras/forms/bulk_import.py:44 netbox/extras/forms/bulk_import.py:157
 #: netbox/extras/forms/bulk_import.py:178
-#: netbox/extras/forms/bulk_import.py:240
-#: netbox/extras/forms/bulk_import.py:264
-#: netbox/extras/forms/bulk_import.py:351 netbox/extras/forms/filtersets.py:60
+#: netbox/extras/forms/bulk_import.py:291
+#: netbox/extras/forms/bulk_import.py:315
+#: netbox/extras/forms/bulk_import.py:402 netbox/extras/forms/filtersets.py:60
 #: netbox/extras/forms/filtersets.py:171 netbox/extras/forms/filtersets.py:267
 #: netbox/extras/forms/filtersets.py:298 netbox/extras/forms/model_forms.py:89
 #: netbox/extras/forms/model_forms.py:347
@@ -10532,9 +10533,9 @@ msgstr ""
 
 #: netbox/extras/forms/bulk_import.py:46 netbox/extras/forms/bulk_import.py:159
 #: netbox/extras/forms/bulk_import.py:180
-#: netbox/extras/forms/bulk_import.py:242
-#: netbox/extras/forms/bulk_import.py:266
-#: netbox/extras/forms/bulk_import.py:353
+#: netbox/extras/forms/bulk_import.py:293
+#: netbox/extras/forms/bulk_import.py:317
+#: netbox/extras/forms/bulk_import.py:404
 #: netbox/tenancy/forms/bulk_import.py:106
 msgid "One or more assigned object types"
 msgstr ""
@@ -10602,79 +10603,94 @@ msgid ""
 "The class of the first link in a group will be used for the dropdown button"
 msgstr ""
 
-#: netbox/extras/forms/bulk_import.py:206
+#: netbox/extras/forms/bulk_import.py:183
+#: netbox/extras/forms/model_forms.py:383
+#: netbox/extras/forms/model_forms.py:862
+msgid "Template code"
+msgstr ""
+
+#: netbox/extras/forms/bulk_import.py:185
+msgid "Jinja2 template code, if not sourced from a data file"
+msgstr ""
+
+#: netbox/extras/forms/bulk_import.py:192
+#: netbox/extras/forms/bulk_import.py:249
 msgid "Data source which provides the data file"
 msgstr ""
 
-#: netbox/extras/forms/bulk_import.py:209 netbox/extras/forms/filtersets.py:211
+#: netbox/extras/forms/bulk_import.py:195
+#: netbox/extras/forms/bulk_import.py:252 netbox/extras/forms/filtersets.py:211
 #: netbox/extras/forms/filtersets.py:434 netbox/extras/forms/filtersets.py:466
 #: netbox/extras/forms/filtersets.py:554 netbox/netbox/choices.py:134
 #: netbox/utilities/forms/bulk_import.py:28
 msgid "Data file"
 msgstr ""
 
-#: netbox/extras/forms/bulk_import.py:213
+#: netbox/extras/forms/bulk_import.py:199
+#: netbox/extras/forms/bulk_import.py:256
 msgid "Data file containing the template code"
 msgstr ""
 
-#: netbox/extras/forms/bulk_import.py:218
+#: netbox/extras/forms/bulk_import.py:204
+#: netbox/extras/forms/bulk_import.py:261
 msgid ""
 "Enable automatic synchronization of template content when the data file is "
 "updated"
 msgstr ""
 
-#: netbox/extras/forms/bulk_import.py:234
+#: netbox/extras/forms/bulk_import.py:229
+#: netbox/extras/forms/bulk_import.py:285
 #: netbox/extras/forms/model_forms.py:414
 #: netbox/extras/forms/model_forms.py:897
 msgid "Must specify either local content or a data file"
 msgstr ""
 
-#: netbox/extras/forms/bulk_import.py:271
+#: netbox/extras/forms/bulk_import.py:322
 msgid "The event type(s) which will trigger this rule"
 msgstr ""
 
-#: netbox/extras/forms/bulk_import.py:274
+#: netbox/extras/forms/bulk_import.py:325
 msgid "Action object"
 msgstr ""
 
-#: netbox/extras/forms/bulk_import.py:277
+#: netbox/extras/forms/bulk_import.py:328
 msgid ""
 "The target object for the action, if it requires one. The expected format "
 "depends on the action type (e.g. a webhook or notification group name, or a "
 "script as dotted path module.Class)."
 msgstr ""
 
-#: netbox/extras/forms/bulk_import.py:300
+#: netbox/extras/forms/bulk_import.py:351
 #, python-brace-format
 msgid "\"{action_type}\" is not a registered action type."
 msgstr ""
 
-#: netbox/extras/forms/bulk_import.py:306
+#: netbox/extras/forms/bulk_import.py:357
 msgid "This action type requires a target object."
 msgstr ""
 
-#: netbox/extras/forms/bulk_import.py:316
+#: netbox/extras/forms/bulk_import.py:367
 msgid "This action type does not operate against a target object."
 msgstr ""
 
-#: netbox/extras/forms/bulk_import.py:323
+#: netbox/extras/forms/bulk_import.py:374
 #, python-brace-format
 msgid "{name} not found"
 msgstr ""
 
-#: netbox/extras/forms/bulk_import.py:327
+#: netbox/extras/forms/bulk_import.py:378
 msgid "This action type does not support bulk import."
 msgstr ""
 
-#: netbox/extras/forms/bulk_import.py:367
+#: netbox/extras/forms/bulk_import.py:418
 msgid "Assigned object type"
 msgstr ""
 
-#: netbox/extras/forms/bulk_import.py:372
+#: netbox/extras/forms/bulk_import.py:423
 msgid "The classification of entry"
 msgstr ""
 
-#: netbox/extras/forms/bulk_import.py:375 netbox/extras/tables/tables.py:793
+#: netbox/extras/forms/bulk_import.py:426 netbox/extras/tables/tables.py:793
 #: netbox/netbox/tables/tables.py:409 netbox/netbox/tables/tables.py:424
 #: netbox/netbox/tables/tables.py:447 netbox/netbox/ui/panels.py:243
 #: netbox/templates/dcim/htmx/cable_edit.html:110
@@ -10684,7 +10700,7 @@ msgstr ""
 msgid "Comments"
 msgstr ""
 
-#: netbox/extras/forms/bulk_import.py:394
+#: netbox/extras/forms/bulk_import.py:445
 #: netbox/extras/forms/model_forms.py:543 netbox/extras/ui/panels.py:324
 #: netbox/netbox/navigation/menu.py:440 netbox/users/forms/filtersets.py:181
 #: netbox/users/forms/model_forms.py:276 netbox/users/forms/model_forms.py:288
@@ -10694,11 +10710,11 @@ msgstr ""
 msgid "Users"
 msgstr ""
 
-#: netbox/extras/forms/bulk_import.py:398
+#: netbox/extras/forms/bulk_import.py:449
 msgid "User names separated by commas, encased with double quotes"
 msgstr ""
 
-#: netbox/extras/forms/bulk_import.py:401
+#: netbox/extras/forms/bulk_import.py:452
 #: netbox/extras/forms/model_forms.py:538 netbox/extras/ui/panels.py:319
 #: netbox/netbox/navigation/menu.py:309 netbox/netbox/navigation/menu.py:441
 #: netbox/tenancy/forms/bulk_edit.py:121 netbox/tenancy/forms/filtersets.py:107
@@ -10711,7 +10727,7 @@ msgstr ""
 msgid "Groups"
 msgstr ""
 
-#: netbox/extras/forms/bulk_import.py:405
+#: netbox/extras/forms/bulk_import.py:456
 msgid "Group names separated by commas, encased with double quotes"
 msgstr ""
 
@@ -10933,11 +10949,6 @@ msgid ""
 "Jinja2 template code for the link URL. Reference the object as {example}."
 msgstr ""
 
-#: netbox/extras/forms/model_forms.py:383
-#: netbox/extras/forms/model_forms.py:862
-msgid "Template code"
-msgstr ""
-
 #: netbox/extras/forms/model_forms.py:389 netbox/extras/ui/panels.py:237
 msgid "Export Template"
 msgstr ""
@@ -14814,7 +14825,11 @@ msgstr ""
 msgid "date synced"
 msgstr ""
 
-#: netbox/netbox/models/features.py:696
+#: netbox/netbox/models/features.py:686
+msgid "The selected data file is empty or its content could not be read."
+msgstr ""
+
+#: netbox/netbox/models/features.py:716
 #, python-brace-format
 msgid "{class_name} must implement a sync_data() method."
 msgstr ""

+ 49 - 0
netbox/users/tests/test_views.py

@@ -1,6 +1,7 @@
 from django.urls import reverse
 
 from core.models import ObjectType
+from dcim.models import Site
 from netbox.choices import CSVDelimiterChoices, ImportFormatChoices
 from users.constants import TOKEN_PREFIX
 from users.models import *
@@ -639,3 +640,51 @@ class OwnerTestCase(ViewTestCases.AdminModelViewTestCase):
         cls.bulk_edit_data = {
             'description': 'New description',
         }
+
+    def test_related_objects_list_owned_objects(self):
+        """An object assigned to an owner appears among the owner's related models."""
+        owner = Owner.objects.get(name='Owner 1')
+        Site.objects.create(name='Site 1', slug='site-1', owner=owner)
+        self.add_permissions('users.view_owner', 'dcim.view_site')
+
+        response = self.client.get(owner.get_absolute_url())
+        self.assertHttpStatus(response, 200)
+
+        related = {roc.queryset.model: roc for roc in response.context['related_models']}
+        self.assertIn(Site, related)
+        self.assertEqual(related[Site].filter_param, 'owner_id')
+        self.assertEqual(related[Site].queryset.count(), 1)
+
+    def test_related_objects_honor_object_permissions(self):
+        """An owned object the user cannot view is omitted from the owner's related models."""
+        owner = Owner.objects.get(name='Owner 1')
+        Site.objects.create(name='Site 1', slug='site-1', owner=owner)
+        self.add_permissions('users.view_owner')
+
+        response = self.client.get(owner.get_absolute_url())
+        self.assertHttpStatus(response, 200)
+
+        self.assertEqual(response.context['related_models'], [])
+
+    def test_related_objects_honor_constrained_permissions(self):
+        """A related model reports only the owned objects the user is permitted to view."""
+        owner = Owner.objects.get(name='Owner 1')
+        site1 = Site.objects.create(name='Site 1', slug='site-1', owner=owner)
+        Site.objects.create(name='Site 2', slug='site-2', owner=owner)
+        self.add_permissions('users.view_owner')
+
+        obj_perm = ObjectPermission(
+            name='Test permission',
+            constraints={'pk': site1.pk},
+            actions=['view']
+        )
+        obj_perm.save()
+        obj_perm.users.add(self.user)
+        obj_perm.object_types.add(ObjectType.objects.get_for_model(Site))
+
+        response = self.client.get(owner.get_absolute_url())
+        self.assertHttpStatus(response, 200)
+
+        related = {roc.queryset.model: roc for roc in response.context['related_models']}
+        self.assertIn(Site, related)
+        self.assertEqual(list(related[Site].queryset), [site1])

+ 1 - 0
netbox/users/views.py

@@ -461,6 +461,7 @@ class OwnerView(GetRelatedModelsMixin, generic.ObjectView):
                 request,
                 instance,
                 omit=(Group, User),
+                include_hidden=True,
             ),
         }
 

+ 8 - 4
netbox/utilities/relations.py

@@ -5,15 +5,19 @@ __all__ = (
 )
 
 
-def get_related_models(model, ordered=True):
+def get_related_models(model, ordered=True, include_hidden=False):
     """
     Return a list of all models which have a ForeignKey to the given model and the name of the field. For example,
-    `get_related_models(Tenant)` will return all models which have a ForeignKey relationship to Tenant.
+    `get_related_models(Tenant)` will return all models which have a ForeignKey relationship to Tenant. Set
+    `include_hidden` to also return relationships declared with `related_name='+'`, excluding the
+    automatically created models behind many-to-many fields.
     """
     related_models = [
         (field.related_model, field.remote_field.name)
-        for field in model._meta.related_objects
-        if type(field) is ManyToOneRel and not getattr(field.related_model, '_netbox_private', False)
+        for field in model._meta.get_fields(include_hidden=include_hidden)
+        if type(field) is ManyToOneRel
+        and not field.related_model._meta.auto_created
+        and not getattr(field.related_model, '_netbox_private', False)
     ]
 
     if ordered:

+ 41 - 0
netbox/utilities/tests/test_relations.py

@@ -0,0 +1,41 @@
+from django.test import TestCase
+
+from dcim.models import Site
+from tenancy.models import Tenant
+from users.models import Owner, User, UserConfig
+from utilities.relations import get_related_models
+
+
+class GetRelatedModelsTestCase(TestCase):
+    """
+    Validate the operation of get_related_models().
+    """
+    def test_visible_relationships_are_returned(self):
+        """An ordinary reverse ForeignKey relationship is reported."""
+        self.assertIn((Site, 'tenant'), get_related_models(Tenant))
+
+    def test_hidden_relationships_are_omitted_by_default(self):
+        """Relationships declared with related_name='+' are not reported unless requested."""
+        self.assertEqual(get_related_models(Owner), [])
+
+    def test_hidden_relationships_are_returned_on_request(self):
+        """include_hidden reports the relationships hidden by related_name='+'."""
+        self.assertIn((Site, 'owner'), get_related_models(Owner, include_hidden=True))
+
+    def test_intermediary_models_are_excluded(self):
+        """The auto-created models behind Owner's many-to-many fields are not reported."""
+        related = get_related_models(Owner, include_hidden=True)
+
+        self.assertEqual([model for model, _ in related if model._meta.auto_created], [])
+
+    def test_private_models_are_excluded(self):
+        """A model flagged _netbox_private is not reported even when hidden relationships are requested."""
+        related = get_related_models(User, include_hidden=True)
+
+        self.assertNotIn(UserConfig, [model for model, _ in related])
+
+    def test_results_are_sorted_by_verbose_name(self):
+        """Ordered results are sorted by the related model's verbose name."""
+        related = get_related_models(Owner, include_hidden=True)
+
+        self.assertEqual(related, sorted(related, key=lambda x: x[0]._meta.verbose_name.lower()))

+ 3 - 2
netbox/utilities/views.py

@@ -200,7 +200,7 @@ class GetRelatedModelsMixin:
         def name(self):
             return self.label or title(_(self.queryset.model._meta.verbose_name_plural))
 
-    def get_related_models(self, request, instance, omit=None, extra=None):
+    def get_related_models(self, request, instance, omit=None, extra=None, include_hidden=False):
         """
         Get related models of the view's `queryset` model without those listed in `omit`. Will be sorted alphabetical.
 
@@ -212,12 +212,13 @@ class GetRelatedModelsMixin:
                 provide a `_list` view.
             extra: Add extra models to the list of automatically determined related models. Can be used to add indirect
                 relationships.
+            include_hidden: Also match relationships declared with `related_name='+'`.
         """
         omit = omit or []
         model = self.queryset.model
         related = filter(
             lambda m: m[0] is not model and m[0] not in omit,
-            get_related_models(model, False)
+            get_related_models(model, ordered=False, include_hidden=include_hidden)
         )
 
         related_models = [