Explorar el Código

Merge branch 'main' into 21879-update-dependent

Arthur hace 2 días
padre
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.
             to include all the requested detail, and then ask that the issue be reopened.
           days-before-stale: 7
           days-before-stale: 7
           days-before-close: 7
           days-before-close: 7
+          days-before-pr-stale: -1
+          days-before-pr-close: -1
           only-issue-labels: 'status: revisions needed'
           only-issue-labels: 'status: revisions needed'
           operations-per-run: 100
           operations-per-run: 100
           remove-stale-when-updated: false
           remove-stale-when-updated: false

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@@ -2077,6 +2077,72 @@ class IPAddressTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         for objectchange in objectchanges:
         for objectchange in objectchanges:
             self.assertEqual(objectchange.message, changelog_message)
             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):
 class FHRPGroupTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = FHRPGroup
     model = FHRPGroup

+ 4 - 4
netbox/ipam/views.py

@@ -26,7 +26,7 @@ from netbox.ui.panels import (
 from netbox.views import generic
 from netbox.views import generic
 from utilities.query import count_related
 from utilities.query import count_related
 from utilities.tables import get_table_ordering
 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.filtersets import VMInterfaceFilterSet
 from virtualization.forms import VMInterfaceFilterForm
 from virtualization.forms import VMInterfaceFilterForm
 from virtualization.models import VirtualMachine, VMInterface
 from virtualization.models import VirtualMachine, VMInterface
@@ -1234,7 +1234,7 @@ class IPAddressEditView(generic.ObjectEditView):
 
 
 # TODO: Standardize or remove this view
 # TODO: Standardize or remove this view
 @register_model_view(IPAddress, 'assign', path='assign', detail=False)
 @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.
     Search for IPAddresses to be assigned to an Interface.
     """
     """
@@ -1253,7 +1253,7 @@ class IPAddressAssignView(generic.ObjectView):
 
 
         return render(request, 'ipam/ipaddress_assign.html', {
         return render(request, 'ipam/ipaddress_assign.html', {
             'form': form,
             'form': form,
-            'return_url': request.GET.get('return_url', ''),
+            'return_url': self.get_return_url(request),
         })
         })
 
 
     def post(self, request):
     def post(self, request):
@@ -1270,7 +1270,7 @@ class IPAddressAssignView(generic.ObjectView):
         return render(request, 'ipam/ipaddress_assign.html', {
         return render(request, 'ipam/ipaddress_assign.html', {
             'form': form,
             'form': form,
             'table': table,
             '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
     Extends the built-in ModelSerializer to enforce calling full_clean() on a copy of the associated instance during
     validation. (DRF does not do this by default; see https://github.com/encode/django-rest-framework/issues/3144)
     validation. (DRF does not do this by default; see https://github.com/encode/django-rest-framework/issues/3144)
+
+    Serializers may declare `model_clean_fields` in Meta, naming scalar model fields whose clean()-normalized values
+    must reach the database. DRF builds a fresh instance from the validated data on create, discarding what clean()
+    wrote.
     """
     """
 
 
     # Bypass DRF's built-in validation of unique constraints due to DRF bug #9410. Rely instead
     # Bypass DRF's built-in validation of unique constraints due to DRF bug #9410. Rely instead
@@ -129,4 +133,7 @@ class ValidatedModelSerializer(BaseModelSerializer):
         if 'custom_field_data' in attrs:
         if 'custom_field_data' in attrs:
             data['custom_field_data'] = instance.custom_field_data
             data['custom_field_data'] = instance.custom_field_data
 
 
+        for field_name in getattr(self.Meta, 'model_clean_fields', ()):
+            data[field_name] = getattr(instance, field_name)
+
         return data
         return data

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

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

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

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

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

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

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

@@ -8,7 +8,7 @@ msgid ""
 msgstr ""
 msgstr ""
 "Project-Id-Version: PACKAGE VERSION\n"
 "Project-Id-Version: PACKAGE VERSION\n"
 "Report-Msgid-Bugs-To: \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"
 "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
 "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
 "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
 "Language-Team: LANGUAGE <LL@li.org>\n"
 "Language-Team: LANGUAGE <LL@li.org>\n"
@@ -2339,7 +2339,8 @@ msgid "File"
 msgstr ""
 msgstr ""
 
 
 #: netbox/core/forms/filtersets.py:70 netbox/core/forms/mixins.py:16
 #: 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:429 netbox/extras/forms/filtersets.py:461
 #: netbox/extras/forms/filtersets.py:549
 #: netbox/extras/forms/filtersets.py:549
 msgid "Data source"
 msgid "Data source"
@@ -2748,11 +2749,11 @@ msgstr ""
 msgid "data files"
 msgid "data files"
 msgstr ""
 msgstr ""
 
 
-#: netbox/core/models/data.py:410
+#: netbox/core/models/data.py:413
 msgid "auto sync record"
 msgid "auto sync record"
 msgstr ""
 msgstr ""
 
 
-#: netbox/core/models/data.py:411
+#: netbox/core/models/data.py:414
 msgid "auto sync records"
 msgid "auto sync records"
 msgstr ""
 msgstr ""
 
 
@@ -6414,7 +6415,7 @@ msgid "Occupied"
 msgstr ""
 msgstr ""
 
 
 #: netbox/dcim/forms/filtersets.py:2033 netbox/extras/forms/bulk_edit.py:449
 #: 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
 #: netbox/extras/forms/model_forms.py:922 netbox/extras/tables/tables.py:790
 msgid "Kind"
 msgid "Kind"
 msgstr ""
 msgstr ""
@@ -9756,13 +9757,13 @@ msgstr ""
 msgid "No config template found for this {object_type}."
 msgid "No config template found for this {object_type}."
 msgstr ""
 msgstr ""
 
 
-#: netbox/extras/api/serializers_/configtemplates.py:51
+#: netbox/extras/api/serializers_/configtemplates.py:53
 msgid ""
 msgid ""
 "Optional ID of the ConfigTemplate to render. If omitted, the object's "
 "Optional ID of the ConfigTemplate to render. If omitted, the object's "
 "assigned config template is used."
 "assigned config template is used."
 msgstr ""
 msgstr ""
 
 
-#: netbox/extras/api/serializers_/configtemplates.py:77
+#: netbox/extras/api/serializers_/configtemplates.py:79
 msgid "The rendered template output."
 msgid "The rendered template output."
 msgstr ""
 msgstr ""
 
 
@@ -10493,7 +10494,7 @@ msgstr ""
 msgid "Timeout"
 msgid "Timeout"
 msgstr ""
 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
 #: netbox/extras/forms/model_forms.py:611
 msgid "Event types"
 msgid "Event types"
 msgstr ""
 msgstr ""
@@ -10510,16 +10511,16 @@ msgstr ""
 msgid "Is active"
 msgid "Is active"
 msgstr ""
 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"
 msgid "Auto sync enabled"
 msgstr ""
 msgstr ""
 
 
 #: netbox/extras/forms/bulk_import.py:44 netbox/extras/forms/bulk_import.py:157
 #: 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: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:171 netbox/extras/forms/filtersets.py:267
 #: netbox/extras/forms/filtersets.py:298 netbox/extras/forms/model_forms.py:89
 #: netbox/extras/forms/filtersets.py:298 netbox/extras/forms/model_forms.py:89
 #: netbox/extras/forms/model_forms.py:347
 #: 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:46 netbox/extras/forms/bulk_import.py:159
 #: netbox/extras/forms/bulk_import.py:180
 #: 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
 #: netbox/tenancy/forms/bulk_import.py:106
 msgid "One or more assigned object types"
 msgid "One or more assigned object types"
 msgstr ""
 msgstr ""
@@ -10602,79 +10603,94 @@ msgid ""
 "The class of the first link in a group will be used for the dropdown button"
 "The class of the first link in a group will be used for the dropdown button"
 msgstr ""
 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"
 msgid "Data source which provides the data file"
 msgstr ""
 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:434 netbox/extras/forms/filtersets.py:466
 #: netbox/extras/forms/filtersets.py:554 netbox/netbox/choices.py:134
 #: netbox/extras/forms/filtersets.py:554 netbox/netbox/choices.py:134
 #: netbox/utilities/forms/bulk_import.py:28
 #: netbox/utilities/forms/bulk_import.py:28
 msgid "Data file"
 msgid "Data file"
 msgstr ""
 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"
 msgid "Data file containing the template code"
 msgstr ""
 msgstr ""
 
 
-#: netbox/extras/forms/bulk_import.py:218
+#: netbox/extras/forms/bulk_import.py:204
+#: netbox/extras/forms/bulk_import.py:261
 msgid ""
 msgid ""
 "Enable automatic synchronization of template content when the data file is "
 "Enable automatic synchronization of template content when the data file is "
 "updated"
 "updated"
 msgstr ""
 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:414
 #: netbox/extras/forms/model_forms.py:897
 #: netbox/extras/forms/model_forms.py:897
 msgid "Must specify either local content or a data file"
 msgid "Must specify either local content or a data file"
 msgstr ""
 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"
 msgid "The event type(s) which will trigger this rule"
 msgstr ""
 msgstr ""
 
 
-#: netbox/extras/forms/bulk_import.py:274
+#: netbox/extras/forms/bulk_import.py:325
 msgid "Action object"
 msgid "Action object"
 msgstr ""
 msgstr ""
 
 
-#: netbox/extras/forms/bulk_import.py:277
+#: netbox/extras/forms/bulk_import.py:328
 msgid ""
 msgid ""
 "The target object for the action, if it requires one. The expected format "
 "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 "
 "depends on the action type (e.g. a webhook or notification group name, or a "
 "script as dotted path module.Class)."
 "script as dotted path module.Class)."
 msgstr ""
 msgstr ""
 
 
-#: netbox/extras/forms/bulk_import.py:300
+#: netbox/extras/forms/bulk_import.py:351
 #, python-brace-format
 #, python-brace-format
 msgid "\"{action_type}\" is not a registered action type."
 msgid "\"{action_type}\" is not a registered action type."
 msgstr ""
 msgstr ""
 
 
-#: netbox/extras/forms/bulk_import.py:306
+#: netbox/extras/forms/bulk_import.py:357
 msgid "This action type requires a target object."
 msgid "This action type requires a target object."
 msgstr ""
 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."
 msgid "This action type does not operate against a target object."
 msgstr ""
 msgstr ""
 
 
-#: netbox/extras/forms/bulk_import.py:323
+#: netbox/extras/forms/bulk_import.py:374
 #, python-brace-format
 #, python-brace-format
 msgid "{name} not found"
 msgid "{name} not found"
 msgstr ""
 msgstr ""
 
 
-#: netbox/extras/forms/bulk_import.py:327
+#: netbox/extras/forms/bulk_import.py:378
 msgid "This action type does not support bulk import."
 msgid "This action type does not support bulk import."
 msgstr ""
 msgstr ""
 
 
-#: netbox/extras/forms/bulk_import.py:367
+#: netbox/extras/forms/bulk_import.py:418
 msgid "Assigned object type"
 msgid "Assigned object type"
 msgstr ""
 msgstr ""
 
 
-#: netbox/extras/forms/bulk_import.py:372
+#: netbox/extras/forms/bulk_import.py:423
 msgid "The classification of entry"
 msgid "The classification of entry"
 msgstr ""
 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:409 netbox/netbox/tables/tables.py:424
 #: netbox/netbox/tables/tables.py:447 netbox/netbox/ui/panels.py:243
 #: netbox/netbox/tables/tables.py:447 netbox/netbox/ui/panels.py:243
 #: netbox/templates/dcim/htmx/cable_edit.html:110
 #: netbox/templates/dcim/htmx/cable_edit.html:110
@@ -10684,7 +10700,7 @@ msgstr ""
 msgid "Comments"
 msgid "Comments"
 msgstr ""
 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/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/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
 #: netbox/users/forms/model_forms.py:276 netbox/users/forms/model_forms.py:288
@@ -10694,11 +10710,11 @@ msgstr ""
 msgid "Users"
 msgid "Users"
 msgstr ""
 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"
 msgid "User names separated by commas, encased with double quotes"
 msgstr ""
 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/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/netbox/navigation/menu.py:309 netbox/netbox/navigation/menu.py:441
 #: netbox/tenancy/forms/bulk_edit.py:121 netbox/tenancy/forms/filtersets.py:107
 #: netbox/tenancy/forms/bulk_edit.py:121 netbox/tenancy/forms/filtersets.py:107
@@ -10711,7 +10727,7 @@ msgstr ""
 msgid "Groups"
 msgid "Groups"
 msgstr ""
 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"
 msgid "Group names separated by commas, encased with double quotes"
 msgstr ""
 msgstr ""
 
 
@@ -10933,11 +10949,6 @@ msgid ""
 "Jinja2 template code for the link URL. Reference the object as {example}."
 "Jinja2 template code for the link URL. Reference the object as {example}."
 msgstr ""
 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
 #: netbox/extras/forms/model_forms.py:389 netbox/extras/ui/panels.py:237
 msgid "Export Template"
 msgid "Export Template"
 msgstr ""
 msgstr ""
@@ -14814,7 +14825,11 @@ msgstr ""
 msgid "date synced"
 msgid "date synced"
 msgstr ""
 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
 #, python-brace-format
 msgid "{class_name} must implement a sync_data() method."
 msgid "{class_name} must implement a sync_data() method."
 msgstr ""
 msgstr ""

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

@@ -1,6 +1,7 @@
 from django.urls import reverse
 from django.urls import reverse
 
 
 from core.models import ObjectType
 from core.models import ObjectType
+from dcim.models import Site
 from netbox.choices import CSVDelimiterChoices, ImportFormatChoices
 from netbox.choices import CSVDelimiterChoices, ImportFormatChoices
 from users.constants import TOKEN_PREFIX
 from users.constants import TOKEN_PREFIX
 from users.models import *
 from users.models import *
@@ -639,3 +640,51 @@ class OwnerTestCase(ViewTestCases.AdminModelViewTestCase):
         cls.bulk_edit_data = {
         cls.bulk_edit_data = {
             'description': 'New description',
             '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,
                 request,
                 instance,
                 instance,
                 omit=(Group, User),
                 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,
     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 = [
     related_models = [
         (field.related_model, field.remote_field.name)
         (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:
     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):
         def name(self):
             return self.label or title(_(self.queryset.model._meta.verbose_name_plural))
             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.
         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.
                 provide a `_list` view.
             extra: Add extra models to the list of automatically determined related models. Can be used to add indirect
             extra: Add extra models to the list of automatically determined related models. Can be used to add indirect
                 relationships.
                 relationships.
+            include_hidden: Also match relationships declared with `related_name='+'`.
         """
         """
         omit = omit or []
         omit = omit or []
         model = self.queryset.model
         model = self.queryset.model
         related = filter(
         related = filter(
             lambda m: m[0] is not model and m[0] not in omit,
             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 = [
         related_models = [