Bladeren bron

Closes #10761: Enable associating an export template with multiple object types

jeremystretch 3 jaren geleden
bovenliggende
commit
16919cc1d9

+ 4 - 1
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
 

+ 3 - 2
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',
         ]
 

+ 5 - 1
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():

+ 0 - 5
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

+ 3 - 3
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',
         )
 
 

+ 2 - 2
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

+ 2 - 2
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')),
     )

+ 1 - 1
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
 
 

+ 40 - 0
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',)},
+        ),
+    ]

+ 5 - 11
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])

+ 5 - 8
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):

+ 9 - 6
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):

+ 11 - 8
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}",

+ 1 - 1
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

+ 12 - 4
netbox/templates/extras/exporttemplate.html

@@ -18,10 +18,6 @@
       </h5>
       <div class="card-body">
         <table class="table table-hover attr-table">
-          <tr>
-            <th scope="row">Content Type</th>
-            <td>{{ object.content_type }}</td>
-          </tr>
           <tr>
             <th scope="row">Name</th>
             <td>{{ object.name }}</td>
@@ -45,6 +41,18 @@
         </table>
       </div>
     </div>
+    <div class="card">
+      <h5 class="card-header">Assigned Models</h5>
+      <div class="card-body">
+        <table class="table table-hover attr-table">
+          {% for ct in object.content_types.all %}
+            <tr>
+              <td>{{ ct }}</td>
+            </tr>
+          {% endfor %}
+        </table>
+      </div>
+    </div>
     {% plugin_left_page object %}
 	</div>
 	<div class="col col-md-7">

+ 1 - 1
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'],