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 @@
-
- Content Type |
- {{ object.content_type }} |
-
Name |
{{ object.name }} |
@@ -45,6 +41,18 @@
+
+
+
+
+ {% for ct in object.content_types.all %}
+
+ {{ ct }} |
+
+ {% endfor %}
+
+
+
{% 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'],