Pārlūkot izejas kodu

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

jeremystretch 3 gadi atpakaļ
vecāks
revīzija
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.
 * 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.
 
 ### New Features
 
@@ -22,6 +23,7 @@ A new `PluginMenu` class has been introduced, which enables a plugin to inject a
 ### Enhancements
 
 * [#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
 * [#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
@@ -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
 * dcim.Rack
     * Added optional `weight` and `weight_unit` fields
+* extras.CustomLink
+    * Renamed `content_type` field to `content_types`
 * ipam.FHRPGroup
     * Added optional `name` field
 

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

@@ -117,14 +117,15 @@ class CustomFieldSerializer(ValidatedModelSerializer):
 
 class CustomLinkSerializer(ValidatedModelSerializer):
     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:
         model = CustomLink
         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',
         ]
 

+ 5 - 1
netbox/extras/filtersets.py

@@ -93,11 +93,15 @@ class CustomLinkFilterSet(BaseFilterSet):
         method='search',
         label='Search',
     )
+    content_type_id = MultiValueNumberFilter(
+        field_name='content_types__id'
+    )
+    content_types = ContentTypeFilter()
 
     class Meta:
         model = CustomLink
         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):

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

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

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

@@ -53,16 +53,16 @@ class CustomFieldCSVForm(CSVModelForm):
 
 
 class CustomLinkCSVForm(CSVModelForm):
-    content_type = CSVContentTypeField(
+    content_types = CSVMultipleContentTypeField(
         queryset=ContentType.objects.all(),
         limit_choices_to=FeatureQuery('custom_links'),
-        help_text="Assigned object type"
+        help_text="One or more assigned object types"
     )
 
     class Meta:
         model = CustomLink
         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',
         )
 

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

@@ -121,9 +121,9 @@ class JobResultFilterForm(FilterForm):
 class CustomLinkFilterForm(FilterForm):
     fieldsets = (
         (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(),
         limit_choices_to=FeatureQuery('custom_links'),
         required=False

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

@@ -63,13 +63,13 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm):
 
 
 class CustomLinkForm(BootstrapMixin, forms.ModelForm):
-    content_type = ContentTypeChoiceField(
+    content_types = ContentTypeMultipleChoiceField(
         queryset=ContentType.objects.all(),
         limit_choices_to=FeatureQuery('custom_links')
     )
 
     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')),
     )
 

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

@@ -35,7 +35,7 @@ class CustomLinkType(ObjectType):
 
     class Meta:
         model = models.CustomLink
-        fields = '__all__'
+        exclude = ('content_types', )
         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
     code to be rendered with an object as context.
     """
-    content_type = models.ForeignKey(
+    content_types = models.ManyToManyField(
         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(
         max_length=100,
@@ -236,7 +236,7 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogged
     )
 
     clone_fields = (
-        'content_type', 'enabled', 'weight', 'group_name', 'button_class', 'new_window',
+        'enabled', 'weight', 'group_name', 'button_class', 'new_window',
     )
 
     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 extras.models import CustomLink
-from utilities.utils import render_jinja2
 
 
 register = template.Library()
@@ -34,7 +33,7 @@ def custom_links(context, obj):
     Render all applicable links for the given object.
     """
     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:
         return ''
 

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

@@ -137,21 +137,21 @@ class CustomLinkTest(APIViewTestCases.APIViewTestCase):
     brief_fields = ['display', 'id', 'name', 'url']
     create_data = [
         {
-            'content_type': 'dcim.site',
+            'content_types': ['dcim.site'],
             'name': 'Custom Link 4',
             'enabled': True,
             'link_text': 'Link 4',
             'link_url': 'http://example.com/?4',
         },
         {
-            'content_type': 'dcim.site',
+            'content_types': ['dcim.site'],
             'name': 'Custom Link 5',
             'enabled': True,
             'link_text': 'Link 5',
             'link_url': 'http://example.com/?5',
         },
         {
-            'content_type': 'dcim.site',
+            'content_types': ['dcim.site'],
             'name': 'Custom Link 6',
             'enabled': False,
             'link_text': 'Link 6',
@@ -169,21 +169,18 @@ class CustomLinkTest(APIViewTestCases.APIViewTestCase):
 
         custom_links = (
             CustomLink(
-                content_type=site_ct,
                 name='Custom Link 1',
                 enabled=True,
                 link_text='Link 1',
                 link_url='http://example.com/?1',
             ),
             CustomLink(
-                content_type=site_ct,
                 name='Custom Link 2',
                 enabled=True,
                 link_text='Link 2',
                 link_url='http://example.com/?2',
             ),
             CustomLink(
-                content_type=site_ct,
                 name='Custom Link 3',
                 enabled=False,
                 link_text='Link 3',
@@ -191,6 +188,8 @@ class CustomLinkTest(APIViewTestCases.APIViewTestCase):
             ),
         )
         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):

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

@@ -168,7 +168,6 @@ class CustomLinkTestCase(TestCase, BaseFilterSetTests):
         custom_links = (
             CustomLink(
                 name='Custom Link 1',
-                content_type=content_types[0],
                 enabled=True,
                 weight=100,
                 new_window=False,
@@ -177,7 +176,6 @@ class CustomLinkTestCase(TestCase, BaseFilterSetTests):
             ),
             CustomLink(
                 name='Custom Link 2',
-                content_type=content_types[1],
                 enabled=True,
                 weight=200,
                 new_window=False,
@@ -186,7 +184,6 @@ class CustomLinkTestCase(TestCase, BaseFilterSetTests):
             ),
             CustomLink(
                 name='Custom Link 3',
-                content_type=content_types[2],
                 enabled=False,
                 weight=300,
                 new_window=True,
@@ -195,13 +192,17 @@ class CustomLinkTestCase(TestCase, BaseFilterSetTests):
             ),
         )
         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):
         params = {'name': ['Custom Link 1', 'Custom Link 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_weight(self):

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

@@ -59,17 +59,19 @@ class CustomLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase):
 
     @classmethod
     def setUpTestData(cls):
-
         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 = {
             'name': 'Custom Link X',
-            'content_type': site_ct.pk,
+            'content_types': [site_ct.pk],
             'enabled': False,
             'weight': 100,
             'button_class': CustomLinkButtonClassChoices.DEFAULT,
@@ -78,7 +80,7 @@ class CustomLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         }
 
         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 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",
@@ -327,13 +329,13 @@ class CustomLinkTest(TestCase):
 
     def test_view_object_with_custom_link(self):
         customlink = CustomLink(
-            content_type=ContentType.objects.get_for_model(Site),
             name='Test',
             link_text='FOO {{ obj.name }} BAR',
             link_url='http://example.com/?site={{ obj.slug }}',
             new_window=False
         )
         customlink.save()
+        customlink.content_types.set([ContentType.objects.get_for_model(Site)])
 
         site = Site(name='Test Site', slug='test-site')
         site.save()

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

@@ -191,7 +191,7 @@ class NetBoxTable(BaseTable):
         extra_columns.extend([
             (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([
             (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="col col-md-5">
     <div class="card">
-      <h5 class="card-header">
-        Custom Link
-      </h5>
+      <h5 class="card-header">Custom Link</h5>
       <div class="card-body">
         <table class="table table-hover attr-table">
           <tr>
             <th scope="row">Name</th>
             <td>{{ object.name }}</td>
           </tr>
-          <tr>
-            <th scope="row">Content Type</th>
-            <td>{{ object.content_type }}</td>
-          </tr>
           <tr>
             <th scope="row">Enabled</th>
             <td>{% checkmark object.enabled %}</td>
@@ -42,6 +36,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">