diff --git a/docs/release-notes/version-3.4.md b/docs/release-notes/version-3.4.md index ba5947364..873967456 100644 --- a/docs/release-notes/version-3.4.md +++ b/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 diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index 99f4dd02b..f8a5862a3 100644 --- a/netbox/extras/api/serializers.py +++ b/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', ] diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index 1b1b049c7..c0114bb58 100644 --- a/netbox/extras/filtersets.py +++ b/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): diff --git a/netbox/extras/forms/bulk_edit.py b/netbox/extras/forms/bulk_edit.py index b1d8a6c21..26c6a195d 100644 --- a/netbox/extras/forms/bulk_edit.py +++ b/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() diff --git a/netbox/extras/forms/bulk_import.py b/netbox/extras/forms/bulk_import.py index 0303dae30..bcc392805 100644 --- a/netbox/extras/forms/bulk_import.py +++ b/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', ) diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py index 059f0d9f2..2e8d4862d 100644 --- a/netbox/extras/forms/filtersets.py +++ b/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 diff --git a/netbox/extras/forms/model_forms.py b/netbox/extras/forms/model_forms.py index eca93849b..8b00c2779 100644 --- a/netbox/extras/forms/model_forms.py +++ b/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')), ) diff --git a/netbox/extras/graphql/types.py b/netbox/extras/graphql/types.py index 41a6103d3..c9f897715 100644 --- a/netbox/extras/graphql/types.py +++ b/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 diff --git a/netbox/extras/migrations/0081_customlink_content_types.py b/netbox/extras/migrations/0081_customlink_content_types.py new file mode 100644 index 000000000..2f0f23509 --- /dev/null +++ b/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', + ), + ] diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index 6d7d2ae04..5c07c360c 100644 --- a/netbox/extras/models/models.py +++ b/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: diff --git a/netbox/extras/templatetags/custom_links.py b/netbox/extras/templatetags/custom_links.py index a73eb3fb4..b7d8d1448 100644 --- a/netbox/extras/templatetags/custom_links.py +++ b/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 '' diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py index 7a9ee487d..c26b95c08 100644 --- a/netbox/extras/tests/test_api.py +++ b/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): diff --git a/netbox/extras/tests/test_filtersets.py b/netbox/extras/tests/test_filtersets.py index 9f9483bbb..3d4dd4cf1 100644 --- a/netbox/extras/tests/test_filtersets.py +++ b/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): diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py index 9634038c1..cfde58782 100644 --- a/netbox/extras/tests/test_views.py +++ b/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() diff --git a/netbox/netbox/tables/tables.py b/netbox/netbox/tables/tables.py index 9b86b2ed3..50c109be8 100644 --- a/netbox/netbox/tables/tables.py +++ b/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 ]) diff --git a/netbox/templates/extras/customlink.html b/netbox/templates/extras/customlink.html index 1f3866182..ff0f7423e 100644 --- a/netbox/templates/extras/customlink.html +++ b/netbox/templates/extras/customlink.html @@ -6,19 +6,13 @@
-
- Custom Link -
+
Custom Link
- - - - @@ -42,6 +36,18 @@
Name {{ object.name }}
Content Type{{ object.content_type }}
Enabled {% checkmark object.enabled %}
+
+
Assigned Models
+
+ + {% for ct in object.content_types.all %} + + + + {% endfor %} +
{{ ct }}
+
+
{% plugin_left_page object %}