Selaa lähdekoodia

Closes #8274: Enable associating a custom link with multiple object types

jeremystretch 3 vuotta sitten
vanhempi
commit
9e8234bb45

+ 4 - 0
docs/release-notes/version-3.4.md

@@ -8,6 +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.
 * 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 `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 `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.
 
 
 ### New Features
 ### New Features
 
 
@@ -22,6 +23,7 @@ A new `PluginMenu` class has been introduced, which enables a plugin to inject a
 ### Enhancements
 ### Enhancements
 
 
 * [#8245](https://github.com/netbox-community/netbox/issues/8245) - Enable GraphQL filtering of related objects
 * [#8245](https://github.com/netbox-community/netbox/issues/8245) - Enable GraphQL filtering of related objects
+* [#8274](https://github.com/netbox-community/netbox/issues/8274) - Enable associating a custom link with multiple object types
 * [#9249](https://github.com/netbox-community/netbox/issues/9249) - Device and virtual machine names are no longer case-sensitive
 * [#9249](https://github.com/netbox-community/netbox/issues/9249) - Device and virtual machine names are no longer case-sensitive
 * [#9478](https://github.com/netbox-community/netbox/issues/9478) - Add `link_peers` field to GraphQL types for cabled objects
 * [#9478](https://github.com/netbox-community/netbox/issues/9478) - Add `link_peers` field to GraphQL types for cabled objects
 * [#9654](https://github.com/netbox-community/netbox/issues/9654) - Add `weight` field to racks, device types, and module types
 * [#9654](https://github.com/netbox-community/netbox/issues/9654) - Add `weight` field to racks, device types, and module types
@@ -57,6 +59,8 @@ A new `PluginMenu` class has been introduced, which enables a plugin to inject a
     * Added optional `weight` and `weight_unit` fields
     * Added optional `weight` and `weight_unit` fields
 * dcim.Rack
 * dcim.Rack
     * Added optional `weight` and `weight_unit` fields
     * Added optional `weight` and `weight_unit` fields
+* extras.CustomLink
+    * Renamed `content_type` field to `content_types`
 * ipam.FHRPGroup
 * ipam.FHRPGroup
     * Added optional `name` field
     * Added optional `name` field
 
 

+ 4 - 3
netbox/extras/api/serializers.py

@@ -117,14 +117,15 @@ class CustomFieldSerializer(ValidatedModelSerializer):
 
 
 class CustomLinkSerializer(ValidatedModelSerializer):
 class CustomLinkSerializer(ValidatedModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='extras-api:customlink-detail')
     url = serializers.HyperlinkedIdentityField(view_name='extras-api:customlink-detail')
-    content_type = ContentTypeField(
-        queryset=ContentType.objects.filter(FeatureQuery('custom_links').get_query())
+    content_types = ContentTypeField(
+        queryset=ContentType.objects.filter(FeatureQuery('custom_links').get_query()),
+        many=True
     )
     )
 
 
     class Meta:
     class Meta:
         model = CustomLink
         model = CustomLink
         fields = [
         fields = [
-            'id', 'url', 'display', 'content_type', 'name', 'enabled', 'link_text', 'link_url', 'weight', 'group_name',
+            'id', 'url', 'display', 'content_types', 'name', 'enabled', 'link_text', 'link_url', 'weight', 'group_name',
             'button_class', 'new_window', 'created', 'last_updated',
             'button_class', 'new_window', 'created', 'last_updated',
         ]
         ]
 
 

+ 5 - 1
netbox/extras/filtersets.py

@@ -93,11 +93,15 @@ class CustomLinkFilterSet(BaseFilterSet):
         method='search',
         method='search',
         label='Search',
         label='Search',
     )
     )
+    content_type_id = MultiValueNumberFilter(
+        field_name='content_types__id'
+    )
+    content_types = ContentTypeFilter()
 
 
     class Meta:
     class Meta:
         model = CustomLink
         model = CustomLink
         fields = [
         fields = [
-            'id', 'content_type', 'name', 'enabled', 'link_text', 'link_url', 'weight', 'group_name', 'new_window',
+            'id', 'content_types', 'name', 'enabled', 'link_text', 'link_url', 'weight', 'group_name', 'new_window',
         ]
         ]
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):

+ 0 - 5
netbox/extras/forms/bulk_edit.py

@@ -53,11 +53,6 @@ class CustomLinkBulkEditForm(BulkEditForm):
         queryset=CustomLink.objects.all(),
         queryset=CustomLink.objects.all(),
         widget=forms.MultipleHiddenInput
         widget=forms.MultipleHiddenInput
     )
     )
-    content_type = ContentTypeChoiceField(
-        queryset=ContentType.objects.all(),
-        limit_choices_to=FeatureQuery('custom_links'),
-        required=False
-    )
     enabled = forms.NullBooleanField(
     enabled = forms.NullBooleanField(
         required=False,
         required=False,
         widget=BulkEditNullBooleanSelect()
         widget=BulkEditNullBooleanSelect()

+ 3 - 3
netbox/extras/forms/bulk_import.py

@@ -53,16 +53,16 @@ class CustomFieldCSVForm(CSVModelForm):
 
 
 
 
 class CustomLinkCSVForm(CSVModelForm):
 class CustomLinkCSVForm(CSVModelForm):
-    content_type = CSVContentTypeField(
+    content_types = CSVMultipleContentTypeField(
         queryset=ContentType.objects.all(),
         queryset=ContentType.objects.all(),
         limit_choices_to=FeatureQuery('custom_links'),
         limit_choices_to=FeatureQuery('custom_links'),
-        help_text="Assigned object type"
+        help_text="One or more assigned object types"
     )
     )
 
 
     class Meta:
     class Meta:
         model = CustomLink
         model = CustomLink
         fields = (
         fields = (
-            'name', 'content_type', 'enabled', 'weight', 'group_name', 'button_class', 'new_window', 'link_text',
+            'name', 'content_types', 'enabled', 'weight', 'group_name', 'button_class', 'new_window', 'link_text',
             'link_url',
             'link_url',
         )
         )
 
 

+ 2 - 2
netbox/extras/forms/filtersets.py

@@ -121,9 +121,9 @@ class JobResultFilterForm(FilterForm):
 class CustomLinkFilterForm(FilterForm):
 class CustomLinkFilterForm(FilterForm):
     fieldsets = (
     fieldsets = (
         (None, ('q',)),
         (None, ('q',)),
-        ('Attributes', ('content_type', 'enabled', 'new_window', 'weight')),
+        ('Attributes', ('content_types', 'enabled', 'new_window', 'weight')),
     )
     )
-    content_type = ContentTypeChoiceField(
+    content_types = ContentTypeMultipleChoiceField(
         queryset=ContentType.objects.all(),
         queryset=ContentType.objects.all(),
         limit_choices_to=FeatureQuery('custom_links'),
         limit_choices_to=FeatureQuery('custom_links'),
         required=False
         required=False

+ 2 - 2
netbox/extras/forms/model_forms.py

@@ -63,13 +63,13 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm):
 
 
 
 
 class CustomLinkForm(BootstrapMixin, forms.ModelForm):
 class CustomLinkForm(BootstrapMixin, forms.ModelForm):
-    content_type = ContentTypeChoiceField(
+    content_types = ContentTypeMultipleChoiceField(
         queryset=ContentType.objects.all(),
         queryset=ContentType.objects.all(),
         limit_choices_to=FeatureQuery('custom_links')
         limit_choices_to=FeatureQuery('custom_links')
     )
     )
 
 
     fieldsets = (
     fieldsets = (
-        ('Custom Link', ('name', 'content_type', 'weight', 'group_name', 'button_class', 'enabled', 'new_window')),
+        ('Custom Link', ('name', 'content_types', 'weight', 'group_name', 'button_class', 'enabled', 'new_window')),
         ('Templates', ('link_text', 'link_url')),
         ('Templates', ('link_text', 'link_url')),
     )
     )
 
 

+ 1 - 1
netbox/extras/graphql/types.py

@@ -35,7 +35,7 @@ class CustomLinkType(ObjectType):
 
 
     class Meta:
     class Meta:
         model = models.CustomLink
         model = models.CustomLink
-        fields = '__all__'
+        exclude = ('content_types', )
         filterset_class = filtersets.CustomLinkFilterSet
         filterset_class = filtersets.CustomLinkFilterSet
 
 
 
 

+ 32 - 0
netbox/extras/migrations/0081_customlink_content_types.py

@@ -0,0 +1,32 @@
+from django.db import migrations, models
+
+
+def copy_content_types(apps, schema_editor):
+    CustomLink = apps.get_model('extras', 'CustomLink')
+
+    for customlink in CustomLink.objects.all():
+        customlink.content_types.set([customlink.content_type])
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('contenttypes', '0002_remove_content_type_name'),
+        ('extras', '0080_search'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='customlink',
+            name='content_types',
+            field=models.ManyToManyField(related_name='custom_links', to='contenttypes.contenttype'),
+        ),
+        migrations.RunPython(
+            code=copy_content_types,
+            reverse_code=migrations.RunPython.noop
+        ),
+        migrations.RemoveField(
+            model_name='customlink',
+            name='content_type',
+        ),
+    ]

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

@@ -197,10 +197,10 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogged
     A custom link to an external representation of a NetBox object. The link text and URL fields accept Jinja2 template
     A custom link to an external representation of a NetBox object. The link text and URL fields accept Jinja2 template
     code to be rendered with an object as context.
     code to be rendered with an object as context.
     """
     """
-    content_type = models.ForeignKey(
+    content_types = models.ManyToManyField(
         to=ContentType,
         to=ContentType,
-        on_delete=models.CASCADE,
-        limit_choices_to=FeatureQuery('custom_links')
+        related_name='custom_links',
+        help_text='The object type(s) to which this link applies.'
     )
     )
     name = models.CharField(
     name = models.CharField(
         max_length=100,
         max_length=100,
@@ -236,7 +236,7 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogged
     )
     )
 
 
     clone_fields = (
     clone_fields = (
-        'content_type', 'enabled', 'weight', 'group_name', 'button_class', 'new_window',
+        'enabled', 'weight', 'group_name', 'button_class', 'new_window',
     )
     )
 
 
     class Meta:
     class Meta:

+ 1 - 2
netbox/extras/templatetags/custom_links.py

@@ -3,7 +3,6 @@ from django.contrib.contenttypes.models import ContentType
 from django.utils.safestring import mark_safe
 from django.utils.safestring import mark_safe
 
 
 from extras.models import CustomLink
 from extras.models import CustomLink
-from utilities.utils import render_jinja2
 
 
 
 
 register = template.Library()
 register = template.Library()
@@ -34,7 +33,7 @@ def custom_links(context, obj):
     Render all applicable links for the given object.
     Render all applicable links for the given object.
     """
     """
     content_type = ContentType.objects.get_for_model(obj)
     content_type = ContentType.objects.get_for_model(obj)
-    custom_links = CustomLink.objects.filter(content_type=content_type, enabled=True)
+    custom_links = CustomLink.objects.filter(content_types=content_type, enabled=True)
     if not custom_links:
     if not custom_links:
         return ''
         return ''
 
 

+ 5 - 6
netbox/extras/tests/test_api.py

@@ -137,21 +137,21 @@ class CustomLinkTest(APIViewTestCases.APIViewTestCase):
     brief_fields = ['display', 'id', 'name', 'url']
     brief_fields = ['display', 'id', 'name', 'url']
     create_data = [
     create_data = [
         {
         {
-            'content_type': 'dcim.site',
+            'content_types': ['dcim.site'],
             'name': 'Custom Link 4',
             'name': 'Custom Link 4',
             'enabled': True,
             'enabled': True,
             'link_text': 'Link 4',
             'link_text': 'Link 4',
             'link_url': 'http://example.com/?4',
             'link_url': 'http://example.com/?4',
         },
         },
         {
         {
-            'content_type': 'dcim.site',
+            'content_types': ['dcim.site'],
             'name': 'Custom Link 5',
             'name': 'Custom Link 5',
             'enabled': True,
             'enabled': True,
             'link_text': 'Link 5',
             'link_text': 'Link 5',
             'link_url': 'http://example.com/?5',
             'link_url': 'http://example.com/?5',
         },
         },
         {
         {
-            'content_type': 'dcim.site',
+            'content_types': ['dcim.site'],
             'name': 'Custom Link 6',
             'name': 'Custom Link 6',
             'enabled': False,
             'enabled': False,
             'link_text': 'Link 6',
             'link_text': 'Link 6',
@@ -169,21 +169,18 @@ class CustomLinkTest(APIViewTestCases.APIViewTestCase):
 
 
         custom_links = (
         custom_links = (
             CustomLink(
             CustomLink(
-                content_type=site_ct,
                 name='Custom Link 1',
                 name='Custom Link 1',
                 enabled=True,
                 enabled=True,
                 link_text='Link 1',
                 link_text='Link 1',
                 link_url='http://example.com/?1',
                 link_url='http://example.com/?1',
             ),
             ),
             CustomLink(
             CustomLink(
-                content_type=site_ct,
                 name='Custom Link 2',
                 name='Custom Link 2',
                 enabled=True,
                 enabled=True,
                 link_text='Link 2',
                 link_text='Link 2',
                 link_url='http://example.com/?2',
                 link_url='http://example.com/?2',
             ),
             ),
             CustomLink(
             CustomLink(
-                content_type=site_ct,
                 name='Custom Link 3',
                 name='Custom Link 3',
                 enabled=False,
                 enabled=False,
                 link_text='Link 3',
                 link_text='Link 3',
@@ -191,6 +188,8 @@ class CustomLinkTest(APIViewTestCases.APIViewTestCase):
             ),
             ),
         )
         )
         CustomLink.objects.bulk_create(custom_links)
         CustomLink.objects.bulk_create(custom_links)
+        for i, custom_link in enumerate(custom_links):
+            custom_link.content_types.set([site_ct])
 
 
 
 
 class ExportTemplateTest(APIViewTestCases.APIViewTestCase):
 class ExportTemplateTest(APIViewTestCases.APIViewTestCase):

+ 6 - 5
netbox/extras/tests/test_filtersets.py

@@ -168,7 +168,6 @@ class CustomLinkTestCase(TestCase, BaseFilterSetTests):
         custom_links = (
         custom_links = (
             CustomLink(
             CustomLink(
                 name='Custom Link 1',
                 name='Custom Link 1',
-                content_type=content_types[0],
                 enabled=True,
                 enabled=True,
                 weight=100,
                 weight=100,
                 new_window=False,
                 new_window=False,
@@ -177,7 +176,6 @@ class CustomLinkTestCase(TestCase, BaseFilterSetTests):
             ),
             ),
             CustomLink(
             CustomLink(
                 name='Custom Link 2',
                 name='Custom Link 2',
-                content_type=content_types[1],
                 enabled=True,
                 enabled=True,
                 weight=200,
                 weight=200,
                 new_window=False,
                 new_window=False,
@@ -186,7 +184,6 @@ class CustomLinkTestCase(TestCase, BaseFilterSetTests):
             ),
             ),
             CustomLink(
             CustomLink(
                 name='Custom Link 3',
                 name='Custom Link 3',
-                content_type=content_types[2],
                 enabled=False,
                 enabled=False,
                 weight=300,
                 weight=300,
                 new_window=True,
                 new_window=True,
@@ -195,13 +192,17 @@ class CustomLinkTestCase(TestCase, BaseFilterSetTests):
             ),
             ),
         )
         )
         CustomLink.objects.bulk_create(custom_links)
         CustomLink.objects.bulk_create(custom_links)
+        for i, custom_link in enumerate(custom_links):
+            custom_link.content_types.set([content_types[i]])
 
 
     def test_name(self):
     def test_name(self):
         params = {'name': ['Custom Link 1', 'Custom Link 2']}
         params = {'name': ['Custom Link 1', 'Custom Link 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 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)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
 
     def test_weight(self):
     def test_weight(self):

+ 11 - 9
netbox/extras/tests/test_views.py

@@ -59,17 +59,19 @@ class CustomLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase):
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
-
         site_ct = ContentType.objects.get_for_model(Site)
         site_ct = ContentType.objects.get_for_model(Site)
-        CustomLink.objects.bulk_create((
-            CustomLink(name='Custom Link 1', content_type=site_ct, enabled=True, link_text='Link 1', link_url='http://example.com/?1'),
-            CustomLink(name='Custom Link 2', content_type=site_ct, enabled=True, link_text='Link 2', link_url='http://example.com/?2'),
-            CustomLink(name='Custom Link 3', content_type=site_ct, enabled=False, link_text='Link 3', link_url='http://example.com/?3'),
-        ))
+        custom_links = (
+            CustomLink(name='Custom Link 1', enabled=True, link_text='Link 1', link_url='http://example.com/?1'),
+            CustomLink(name='Custom Link 2', enabled=True, link_text='Link 2', link_url='http://example.com/?2'),
+            CustomLink(name='Custom Link 3', enabled=False, link_text='Link 3', link_url='http://example.com/?3'),
+        )
+        CustomLink.objects.bulk_create(custom_links)
+        for i, custom_link in enumerate(custom_links):
+            custom_link.content_types.set([site_ct])
 
 
         cls.form_data = {
         cls.form_data = {
             'name': 'Custom Link X',
             'name': 'Custom Link X',
-            'content_type': site_ct.pk,
+            'content_types': [site_ct.pk],
             'enabled': False,
             'enabled': False,
             'weight': 100,
             'weight': 100,
             'button_class': CustomLinkButtonClassChoices.DEFAULT,
             'button_class': CustomLinkButtonClassChoices.DEFAULT,
@@ -78,7 +80,7 @@ class CustomLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         }
         }
 
 
         cls.csv_data = (
         cls.csv_data = (
-            "name,content_type,enabled,weight,button_class,link_text,link_url",
+            "name,content_types,enabled,weight,button_class,link_text,link_url",
             "Custom Link 4,dcim.site,True,100,blue,Link 4,http://exmaple.com/?4",
             "Custom Link 4,dcim.site,True,100,blue,Link 4,http://exmaple.com/?4",
             "Custom Link 5,dcim.site,True,100,blue,Link 5,http://exmaple.com/?5",
             "Custom Link 5,dcim.site,True,100,blue,Link 5,http://exmaple.com/?5",
             "Custom Link 6,dcim.site,False,100,blue,Link 6,http://exmaple.com/?6",
             "Custom Link 6,dcim.site,False,100,blue,Link 6,http://exmaple.com/?6",
@@ -327,13 +329,13 @@ class CustomLinkTest(TestCase):
 
 
     def test_view_object_with_custom_link(self):
     def test_view_object_with_custom_link(self):
         customlink = CustomLink(
         customlink = CustomLink(
-            content_type=ContentType.objects.get_for_model(Site),
             name='Test',
             name='Test',
             link_text='FOO {{ obj.name }} BAR',
             link_text='FOO {{ obj.name }} BAR',
             link_url='http://example.com/?site={{ obj.slug }}',
             link_url='http://example.com/?site={{ obj.slug }}',
             new_window=False
             new_window=False
         )
         )
         customlink.save()
         customlink.save()
+        customlink.content_types.set([ContentType.objects.get_for_model(Site)])
 
 
         site = Site(name='Test Site', slug='test-site')
         site = Site(name='Test Site', slug='test-site')
         site.save()
         site.save()

+ 1 - 1
netbox/netbox/tables/tables.py

@@ -191,7 +191,7 @@ class NetBoxTable(BaseTable):
         extra_columns.extend([
         extra_columns.extend([
             (f'cf_{cf.name}', columns.CustomFieldColumn(cf)) for cf in custom_fields
             (f'cf_{cf.name}', columns.CustomFieldColumn(cf)) for cf in custom_fields
         ])
         ])
-        custom_links = CustomLink.objects.filter(content_type=content_type, enabled=True)
+        custom_links = CustomLink.objects.filter(content_types=content_type, enabled=True)
         extra_columns.extend([
         extra_columns.extend([
             (f'cl_{cl.name}', columns.CustomLinkColumn(cl)) for cl in custom_links
             (f'cl_{cl.name}', columns.CustomLinkColumn(cl)) for cl in custom_links
         ])
         ])

+ 13 - 7
netbox/templates/extras/customlink.html

@@ -6,19 +6,13 @@
 <div class="row mb-3">
 <div class="row mb-3">
 	<div class="col col-md-5">
 	<div class="col col-md-5">
     <div class="card">
     <div class="card">
-      <h5 class="card-header">
-        Custom Link
-      </h5>
+      <h5 class="card-header">Custom Link</h5>
       <div class="card-body">
       <div class="card-body">
         <table class="table table-hover attr-table">
         <table class="table table-hover attr-table">
           <tr>
           <tr>
             <th scope="row">Name</th>
             <th scope="row">Name</th>
             <td>{{ object.name }}</td>
             <td>{{ object.name }}</td>
           </tr>
           </tr>
-          <tr>
-            <th scope="row">Content Type</th>
-            <td>{{ object.content_type }}</td>
-          </tr>
           <tr>
           <tr>
             <th scope="row">Enabled</th>
             <th scope="row">Enabled</th>
             <td>{% checkmark object.enabled %}</td>
             <td>{% checkmark object.enabled %}</td>
@@ -42,6 +36,18 @@
         </table>
         </table>
       </div>
       </div>
     </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 %}
     {% plugin_left_page object %}
 	</div>
 	</div>
 	<div class="col col-md-7">
 	<div class="col col-md-7">