diff --git a/docs/release-notes/version-3.4.md b/docs/release-notes/version-3.4.md index 873967456..93e2c8841 100644 --- a/docs/release-notes/version-3.4.md +++ b/docs/release-notes/version-3.4.md @@ -8,7 +8,7 @@ * Device and virtual machine names are no longer case-sensitive. Attempting to create e.g. "device1" and "DEVICE1" will raise a validation error. * The `asn` field has been removed from the provider model. Please replicate any provider ASN assignments to the ASN model introduced in NetBox v3.1 prior to upgrading. * The `noc_contact`, `admin_contact`, and `portal_url` fields have been removed from the provider model. Please replicate any data remaining in these fields to the contact model introduced in NetBox v3.1 prior to upgrading. -* The `content_type` field on the CustomLink model has been renamed to `content_types` and now supports the assignment of multiple content types. +* The `content_type` field on the CustomLink and ExportTemplate models have been renamed to `content_types` and now supports the assignment of multiple content types. ### New Features @@ -32,6 +32,7 @@ A new `PluginMenu` class has been introduced, which enables a plugin to inject a * [#10348](https://github.com/netbox-community/netbox/issues/10348) - Add decimal custom field type * [#10556](https://github.com/netbox-community/netbox/issues/10556) - Include a `display` field in all GraphQL object types * [#10595](https://github.com/netbox-community/netbox/issues/10595) - Add GraphQL relationships for additional generic foreign key fields +* [#10761](https://github.com/netbox-community/netbox/issues/10761) - Enable associating an export template with multiple object types ### Plugins API @@ -61,6 +62,8 @@ A new `PluginMenu` class has been introduced, which enables a plugin to inject a * Added optional `weight` and `weight_unit` fields * extras.CustomLink * Renamed `content_type` field to `content_types` +* extras.ExportTemplate + * Renamed `content_type` field to `content_types` * ipam.FHRPGroup * Added optional `name` field diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index f8a5862a3..ac025ff16 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -136,14 +136,15 @@ class CustomLinkSerializer(ValidatedModelSerializer): class ExportTemplateSerializer(ValidatedModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='extras-api:exporttemplate-detail') - content_type = ContentTypeField( + content_types = ContentTypeField( queryset=ContentType.objects.filter(FeatureQuery('export_templates').get_query()), + many=True ) class Meta: model = ExportTemplate fields = [ - 'id', 'url', 'display', 'content_type', 'name', 'description', 'template_code', 'mime_type', + 'id', 'url', 'display', 'content_types', 'name', 'description', 'template_code', 'mime_type', 'file_extension', 'as_attachment', 'created', 'last_updated', ] diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index c0114bb58..22fe6537e 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -120,10 +120,14 @@ class ExportTemplateFilterSet(BaseFilterSet): method='search', label='Search', ) + content_type_id = MultiValueNumberFilter( + field_name='content_types__id' + ) + content_types = ContentTypeFilter() class Meta: model = ExportTemplate - fields = ['id', 'content_type', 'name', 'description'] + fields = ['id', 'content_types', 'name', 'description'] def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/extras/forms/bulk_edit.py b/netbox/extras/forms/bulk_edit.py index 26c6a195d..df17324ec 100644 --- a/netbox/extras/forms/bulk_edit.py +++ b/netbox/extras/forms/bulk_edit.py @@ -76,11 +76,6 @@ class ExportTemplateBulkEditForm(BulkEditForm): queryset=ExportTemplate.objects.all(), widget=forms.MultipleHiddenInput ) - content_type = ContentTypeChoiceField( - queryset=ContentType.objects.all(), - limit_choices_to=FeatureQuery('export_templates'), - required=False - ) description = forms.CharField( max_length=200, required=False diff --git a/netbox/extras/forms/bulk_import.py b/netbox/extras/forms/bulk_import.py index bcc392805..ee638015b 100644 --- a/netbox/extras/forms/bulk_import.py +++ b/netbox/extras/forms/bulk_import.py @@ -68,16 +68,16 @@ class CustomLinkCSVForm(CSVModelForm): class ExportTemplateCSVForm(CSVModelForm): - content_type = CSVContentTypeField( + content_types = CSVMultipleContentTypeField( queryset=ContentType.objects.all(), limit_choices_to=FeatureQuery('export_templates'), - help_text="Assigned object type" + help_text="One or more assigned object types" ) class Meta: model = ExportTemplate fields = ( - 'name', 'content_type', 'description', 'mime_type', 'file_extension', 'as_attachment', 'template_code', + 'name', 'content_types', 'description', 'mime_type', 'file_extension', 'as_attachment', 'template_code', ) diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py index 2e8d4862d..a164a3d95 100644 --- a/netbox/extras/forms/filtersets.py +++ b/netbox/extras/forms/filtersets.py @@ -148,9 +148,9 @@ class CustomLinkFilterForm(FilterForm): class ExportTemplateFilterForm(FilterForm): fieldsets = ( (None, ('q',)), - ('Attributes', ('content_type', 'mime_type', 'file_extension', 'as_attachment')), + ('Attributes', ('content_types', 'mime_type', 'file_extension', 'as_attachment')), ) - content_type = ContentTypeChoiceField( + content_types = ContentTypeMultipleChoiceField( queryset=ContentType.objects.all(), limit_choices_to=FeatureQuery('export_templates'), required=False diff --git a/netbox/extras/forms/model_forms.py b/netbox/extras/forms/model_forms.py index 8b00c2779..7ff4f3e27 100644 --- a/netbox/extras/forms/model_forms.py +++ b/netbox/extras/forms/model_forms.py @@ -89,13 +89,13 @@ class CustomLinkForm(BootstrapMixin, forms.ModelForm): class ExportTemplateForm(BootstrapMixin, forms.ModelForm): - content_type = ContentTypeChoiceField( + content_types = ContentTypeMultipleChoiceField( queryset=ContentType.objects.all(), limit_choices_to=FeatureQuery('export_templates') ) fieldsets = ( - ('Export Template', ('name', 'content_type', 'description')), + ('Export Template', ('name', 'content_types', 'description')), ('Template', ('template_code',)), ('Rendering', ('mime_type', 'file_extension', 'as_attachment')), ) diff --git a/netbox/extras/graphql/types.py b/netbox/extras/graphql/types.py index c9f897715..3be7b371e 100644 --- a/netbox/extras/graphql/types.py +++ b/netbox/extras/graphql/types.py @@ -43,7 +43,7 @@ class ExportTemplateType(ObjectType): class Meta: model = models.ExportTemplate - fields = '__all__' + exclude = ('content_types', ) filterset_class = filtersets.ExportTemplateFilterSet diff --git a/netbox/extras/migrations/0082_exporttemplate_content_types.py b/netbox/extras/migrations/0082_exporttemplate_content_types.py new file mode 100644 index 000000000..34a9c77e6 --- /dev/null +++ b/netbox/extras/migrations/0082_exporttemplate_content_types.py @@ -0,0 +1,40 @@ +from django.db import migrations, models + + +def copy_content_types(apps, schema_editor): + ExportTemplate = apps.get_model('extras', 'ExportTemplate') + + for et in ExportTemplate.objects.all(): + et.content_types.set([et.content_type]) + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('extras', '0081_customlink_content_types'), + ] + + operations = [ + migrations.AddField( + model_name='exporttemplate', + name='content_types', + field=models.ManyToManyField(related_name='export_templates', to='contenttypes.contenttype'), + ), + migrations.RunPython( + code=copy_content_types, + reverse_code=migrations.RunPython.noop + ), + migrations.RemoveConstraint( + model_name='exporttemplate', + name='extras_exporttemplate_unique_content_type_name', + ), + migrations.RemoveField( + model_name='exporttemplate', + name='content_type', + ), + migrations.AlterModelOptions( + name='exporttemplate', + options={'ordering': ('name',)}, + ), + ] diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index 5c07c360c..a8b2f2647 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -268,10 +268,10 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogged class ExportTemplate(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): - content_type = models.ForeignKey( + content_types = models.ManyToManyField( to=ContentType, - on_delete=models.CASCADE, - limit_choices_to=FeatureQuery('export_templates') + related_name='export_templates', + help_text='The object type(s) to which this template applies.' ) name = models.CharField( max_length=100 @@ -301,16 +301,10 @@ class ExportTemplate(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): ) class Meta: - ordering = ['content_type', 'name'] - constraints = ( - models.UniqueConstraint( - fields=('content_type', 'name'), - name='%(app_label)s_%(class)s_unique_content_type_name' - ), - ) + ordering = ('name',) def __str__(self): - return f"{self.content_type}: {self.name}" + return self.name def get_absolute_url(self): return reverse('extras:exporttemplate', args=[self.pk]) diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py index c26b95c08..42246b651 100644 --- a/netbox/extras/tests/test_api.py +++ b/netbox/extras/tests/test_api.py @@ -197,17 +197,17 @@ class ExportTemplateTest(APIViewTestCases.APIViewTestCase): brief_fields = ['display', 'id', 'name', 'url'] create_data = [ { - 'content_type': 'dcim.device', + 'content_types': ['dcim.device'], 'name': 'Test Export Template 4', 'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}', }, { - 'content_type': 'dcim.device', + 'content_types': ['dcim.device'], 'name': 'Test Export Template 5', 'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}', }, { - 'content_type': 'dcim.device', + 'content_types': ['dcim.device'], 'name': 'Test Export Template 6', 'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}', }, @@ -218,26 +218,23 @@ class ExportTemplateTest(APIViewTestCases.APIViewTestCase): @classmethod def setUpTestData(cls): - ct = ContentType.objects.get_for_model(Device) - export_templates = ( ExportTemplate( - content_type=ct, name='Export Template 1', template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}' ), ExportTemplate( - content_type=ct, name='Export Template 2', template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}' ), ExportTemplate( - content_type=ct, name='Export Template 3', template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}' ), ) ExportTemplate.objects.bulk_create(export_templates) + for et in export_templates: + et.content_types.set([ContentType.objects.get_for_model(Device)]) class TagTest(APIViewTestCases.APIViewTestCase): diff --git a/netbox/extras/tests/test_filtersets.py b/netbox/extras/tests/test_filtersets.py index 3d4dd4cf1..dd1fdb6b3 100644 --- a/netbox/extras/tests/test_filtersets.py +++ b/netbox/extras/tests/test_filtersets.py @@ -228,22 +228,25 @@ class ExportTemplateTestCase(TestCase, BaseFilterSetTests): @classmethod def setUpTestData(cls): - content_types = ContentType.objects.filter(model__in=['site', 'rack', 'device']) export_templates = ( - ExportTemplate(name='Export Template 1', content_type=content_types[0], template_code='TESTING', description='foobar1'), - ExportTemplate(name='Export Template 2', content_type=content_types[1], template_code='TESTING', description='foobar2'), - ExportTemplate(name='Export Template 3', content_type=content_types[2], template_code='TESTING'), + ExportTemplate(name='Export Template 1', template_code='TESTING', description='foobar1'), + ExportTemplate(name='Export Template 2', template_code='TESTING', description='foobar2'), + ExportTemplate(name='Export Template 3', template_code='TESTING'), ) ExportTemplate.objects.bulk_create(export_templates) + for i, et in enumerate(export_templates): + et.content_types.set([content_types[i]]) def test_name(self): params = {'name': ['Export Template 1', 'Export Template 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_content_type(self): - params = {'content_type': ContentType.objects.get(model='site').pk} + def test_content_types(self): + params = {'content_types': 'dcim.site'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + params = {'content_type_id': [ContentType.objects.get_for_model(Site).pk]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) def test_description(self): diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py index cfde58782..da11d42ad 100644 --- a/netbox/extras/tests/test_views.py +++ b/netbox/extras/tests/test_views.py @@ -98,23 +98,26 @@ class ExportTemplateTestCase(ViewTestCases.PrimaryObjectViewTestCase): @classmethod def setUpTestData(cls): - site_ct = ContentType.objects.get_for_model(Site) TEMPLATE_CODE = """{% for object in queryset %}{{ object }}{% endfor %}""" - ExportTemplate.objects.bulk_create(( - ExportTemplate(name='Export Template 1', content_type=site_ct, template_code=TEMPLATE_CODE), - ExportTemplate(name='Export Template 2', content_type=site_ct, template_code=TEMPLATE_CODE), - ExportTemplate(name='Export Template 3', content_type=site_ct, template_code=TEMPLATE_CODE), - )) + + export_templates = ( + ExportTemplate(name='Export Template 1', template_code=TEMPLATE_CODE), + ExportTemplate(name='Export Template 2', template_code=TEMPLATE_CODE), + ExportTemplate(name='Export Template 3', template_code=TEMPLATE_CODE), + ) + ExportTemplate.objects.bulk_create(export_templates) + for et in export_templates: + et.content_types.set([site_ct]) cls.form_data = { 'name': 'Export Template X', - 'content_type': site_ct.pk, + 'content_types': [site_ct.pk], 'template_code': TEMPLATE_CODE, } cls.csv_data = ( - "name,content_type,template_code", + "name,content_types,template_code", f"Export Template 4,dcim.site,{TEMPLATE_CODE}", f"Export Template 5,dcim.site,{TEMPLATE_CODE}", f"Export Template 6,dcim.site,{TEMPLATE_CODE}", diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index f0741af2c..69f9842ca 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -142,7 +142,7 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin): # Render an ExportTemplate elif request.GET['export']: - template = get_object_or_404(ExportTemplate, content_type=content_type, name=request.GET['export']) + template = get_object_or_404(ExportTemplate, content_types=content_type, name=request.GET['export']) return self.export_template(template, request) # Check for YAML export support on the model diff --git a/netbox/templates/extras/exporttemplate.html b/netbox/templates/extras/exporttemplate.html index 912702b86..d14294355 100644 --- a/netbox/templates/extras/exporttemplate.html +++ b/netbox/templates/extras/exporttemplate.html @@ -18,10 +18,6 @@
- - - - @@ -45,6 +41,18 @@
Content Type{{ object.content_type }}
Name {{ object.name }}
+
+
Assigned Models
+
+ + {% for ct in object.content_types.all %} + + + + {% endfor %} +
{{ ct }}
+
+
{% plugin_left_page object %}
diff --git a/netbox/utilities/templatetags/buttons.py b/netbox/utilities/templatetags/buttons.py index 4b8178405..bcdb099d8 100644 --- a/netbox/utilities/templatetags/buttons.py +++ b/netbox/utilities/templatetags/buttons.py @@ -83,7 +83,7 @@ def export_button(context, model): data_format = 'YAML' if hasattr(content_type.model_class(), 'to_yaml') else 'CSV' # Retrieve all export templates for this model - export_templates = ExportTemplate.objects.restrict(user, 'view').filter(content_type=content_type) + export_templates = ExportTemplate.objects.restrict(user, 'view').filter(content_types=content_type) return { 'perms': context['perms'],