diff --git a/docs/models/extras/customlink.md b/docs/models/extras/customlink.md index 7fd510841aa..96ff0bbf7e4 100644 --- a/docs/models/extras/customlink.md +++ b/docs/models/extras/customlink.md @@ -15,7 +15,7 @@ When viewing a device named Router4, this link would render as: View NMS ``` -Custom links appear as buttons in the top right corner of the page. Numeric weighting can be used to influence the ordering of links. +Custom links appear as buttons in the top right corner of the page. Numeric weighting can be used to influence the ordering of links, and each link can be enabled or disabled individually. !!! warning Custom links rely on user-created code to generate arbitrary HTML output, which may be dangerous. Only grant permission to create or modify custom links to trusted users. diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index e0ef639fa90..31025bb853c 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -64,6 +64,7 @@ Inventory item templates can be arranged hierarchically within a device type, an * [#7846](https://github.com/netbox-community/netbox/issues/7846) - Enable associating inventory items with device components * [#7852](https://github.com/netbox-community/netbox/issues/7852) - Enable assigning interfaces to VRFs * [#8168](https://github.com/netbox-community/netbox/issues/8168) - Add `min_vid` and `max_vid` fields to VLAN group +* [#8296](https://github.com/netbox-community/netbox/issues/8296) - Allow disabling custom links ### Other Changes @@ -106,6 +107,8 @@ Inventory item templates can be arranged hierarchically within a device type, an * Add `cluster_types` field * extras.CustomField * Added `object_type` field +* extras.CustomLink + * Added `enabled` field * ipam.VLANGroup * Added the `/availables-vlans/` endpoint * Added the `min_vid` and `max_vid` fields diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index fa0e5189f00..6279ea2b7af 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -101,7 +101,7 @@ class CustomLinkSerializer(ValidatedModelSerializer): class Meta: model = CustomLink fields = [ - 'id', 'url', 'display', 'content_type', 'name', 'link_text', 'link_url', 'weight', 'group_name', + 'id', 'url', 'display', 'content_type', 'name', 'enabled', 'link_text', 'link_url', 'weight', 'group_name', 'button_class', 'new_window', ] diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index bf25ff76c42..a839e2dd353 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -82,7 +82,9 @@ class CustomLinkFilterSet(BaseFilterSet): class Meta: model = CustomLink - fields = ['id', 'content_type', 'name', 'link_text', 'link_url', 'weight', 'group_name', 'new_window'] + fields = [ + 'id', 'content_type', 'name', 'enabled', 'link_text', 'link_url', 'weight', 'group_name', 'new_window', + ] 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 1b87256a5be..56b51c89404 100644 --- a/netbox/extras/forms/bulk_edit.py +++ b/netbox/extras/forms/bulk_edit.py @@ -47,6 +47,10 @@ class CustomLinkBulkEditForm(BulkEditForm): limit_choices_to=FeatureQuery('custom_fields'), required=False ) + enabled = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect() + ) new_window = forms.NullBooleanField( required=False, widget=BulkEditNullBooleanSelect() diff --git a/netbox/extras/forms/bulk_import.py b/netbox/extras/forms/bulk_import.py index 9f44494e030..fa6d8af5512 100644 --- a/netbox/extras/forms/bulk_import.py +++ b/netbox/extras/forms/bulk_import.py @@ -51,7 +51,8 @@ class CustomLinkCSVForm(CSVModelForm): class Meta: model = CustomLink fields = ( - 'name', 'content_type', 'weight', 'group_name', 'button_class', 'new_window', 'link_text', 'link_url', + 'name', 'content_type', '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 388cd1e60ef..330bb91e349 100644 --- a/netbox/extras/forms/filtersets.py +++ b/netbox/extras/forms/filtersets.py @@ -58,15 +58,18 @@ class CustomFieldFilterForm(FilterForm): class CustomLinkFilterForm(FilterForm): field_groups = [ ['q'], - ['content_type', 'weight', 'new_window'], + ['content_type', 'enabled', 'new_window', 'weight'], ] content_type = ContentTypeChoiceField( queryset=ContentType.objects.all(), limit_choices_to=FeatureQuery('custom_fields'), required=False ) - weight = forms.IntegerField( - required=False + enabled = forms.NullBooleanField( + required=False, + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) ) new_window = forms.NullBooleanField( required=False, @@ -74,6 +77,9 @@ class CustomLinkFilterForm(FilterForm): choices=BOOLEAN_WITH_BLANK_CHOICES ) ) + weight = forms.IntegerField( + required=False + ) class ExportTemplateFilterForm(FilterForm): diff --git a/netbox/extras/forms/models.py b/netbox/extras/forms/models.py index 55e58a7f21d..ca2c6b90063 100644 --- a/netbox/extras/forms/models.py +++ b/netbox/extras/forms/models.py @@ -53,7 +53,7 @@ class CustomLinkForm(BootstrapMixin, forms.ModelForm): model = CustomLink fields = '__all__' fieldsets = ( - ('Custom Link', ('name', 'content_type', 'weight', 'group_name', 'button_class', 'new_window')), + ('Custom Link', ('name', 'content_type', 'weight', 'group_name', 'button_class', 'enabled', 'new_window')), ('Templates', ('link_text', 'link_url')), ) widgets = { diff --git a/netbox/extras/migrations/0070_customlink_enabled.py b/netbox/extras/migrations/0070_customlink_enabled.py new file mode 100644 index 00000000000..839a4dba5c2 --- /dev/null +++ b/netbox/extras/migrations/0070_customlink_enabled.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.11 on 2022-01-10 16:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0069_custom_object_field'), + ] + + operations = [ + migrations.AddField( + model_name='customlink', + name='enabled', + field=models.BooleanField(default=True), + ), + ] diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index ac3a23410d9..3612b2a6fae 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -192,6 +192,9 @@ class CustomLink(ChangeLoggedModel): max_length=100, unique=True ) + enabled = models.BooleanField( + default=True + ) link_text = models.CharField( max_length=500, help_text="Jinja2 template code for link text" diff --git a/netbox/extras/tables.py b/netbox/extras/tables.py index 071caa354e5..adfccb575da 100644 --- a/netbox/extras/tables.py +++ b/netbox/extras/tables.py @@ -73,15 +73,16 @@ class CustomLinkTable(BaseTable): linkify=True ) content_type = ContentTypeColumn() + enabled = BooleanColumn() new_window = BooleanColumn() class Meta(BaseTable.Meta): model = CustomLink fields = ( - 'pk', 'id', 'name', 'content_type', 'link_text', 'link_url', 'weight', 'group_name', + 'pk', 'id', 'name', 'content_type', 'enabled', 'link_text', 'link_url', 'weight', 'group_name', 'button_class', 'new_window', ) - default_columns = ('pk', 'name', 'content_type', 'group_name', 'button_class', 'new_window') + default_columns = ('pk', 'name', 'content_type', 'enabled', 'group_name', 'button_class', 'new_window') # diff --git a/netbox/extras/templatetags/custom_links.py b/netbox/extras/templatetags/custom_links.py index 32ec966b35c..dd546733884 100644 --- a/netbox/extras/templatetags/custom_links.py +++ b/netbox/extras/templatetags/custom_links.py @@ -36,7 +36,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) + custom_links = CustomLink.objects.filter(content_type=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 d15b57e4376..d790eff7187 100644 --- a/netbox/extras/tests/test_api.py +++ b/netbox/extras/tests/test_api.py @@ -139,24 +139,28 @@ class CustomLinkTest(APIViewTestCases.APIViewTestCase): { 'content_type': 'dcim.site', 'name': 'Custom Link 4', + 'enabled': True, 'link_text': 'Link 4', 'link_url': 'http://example.com/?4', }, { 'content_type': 'dcim.site', 'name': 'Custom Link 5', + 'enabled': True, 'link_text': 'Link 5', 'link_url': 'http://example.com/?5', }, { 'content_type': 'dcim.site', 'name': 'Custom Link 6', + 'enabled': False, 'link_text': 'Link 6', 'link_url': 'http://example.com/?6', }, ] bulk_update_data = { 'new_window': True, + 'enabled': False, } @classmethod @@ -167,18 +171,21 @@ class CustomLinkTest(APIViewTestCases.APIViewTestCase): 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', link_url='http://example.com/?3', ), diff --git a/netbox/extras/tests/test_filtersets.py b/netbox/extras/tests/test_filtersets.py index a5f77afa9f7..3a08055cb5f 100644 --- a/netbox/extras/tests/test_filtersets.py +++ b/netbox/extras/tests/test_filtersets.py @@ -100,6 +100,7 @@ class CustomLinkTestCase(TestCase, BaseFilterSetTests): CustomLink( name='Custom Link 1', content_type=content_types[0], + enabled=True, weight=100, new_window=False, link_text='Link 1', @@ -108,6 +109,7 @@ class CustomLinkTestCase(TestCase, BaseFilterSetTests): CustomLink( name='Custom Link 2', content_type=content_types[1], + enabled=True, weight=200, new_window=False, link_text='Link 1', @@ -116,6 +118,7 @@ class CustomLinkTestCase(TestCase, BaseFilterSetTests): CustomLink( name='Custom Link 3', content_type=content_types[2], + enabled=False, weight=300, new_window=True, link_text='Link 1', @@ -136,6 +139,12 @@ class CustomLinkTestCase(TestCase, BaseFilterSetTests): params = {'weight': [100, 200]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_enabled(self): + params = {'enabled': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'enabled': False} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + def test_new_window(self): params = {'new_window': False} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py index 34d5cb67e27..ea3a952d61b 100644 --- a/netbox/extras/tests/test_views.py +++ b/netbox/extras/tests/test_views.py @@ -59,14 +59,15 @@ class CustomLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase): site_ct = ContentType.objects.get_for_model(Site) CustomLink.objects.bulk_create(( - CustomLink(name='Custom Link 1', content_type=site_ct, link_text='Link 1', link_url='http://example.com/?1'), - CustomLink(name='Custom Link 2', content_type=site_ct, link_text='Link 2', link_url='http://example.com/?2'), - CustomLink(name='Custom Link 3', content_type=site_ct, link_text='Link 3', link_url='http://example.com/?3'), + 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'), )) cls.form_data = { 'name': 'Custom Link X', 'content_type': site_ct.pk, + 'enabled': False, 'weight': 100, 'button_class': CustomLinkButtonClassChoices.DEFAULT, 'link_text': 'Link X', @@ -74,14 +75,15 @@ class CustomLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase): } cls.csv_data = ( - "name,content_type,weight,button_class,link_text,link_url", - "Custom Link 4,dcim.site,100,blue,Link 4,http://exmaple.com/?4", - "Custom Link 5,dcim.site,100,blue,Link 5,http://exmaple.com/?5", - "Custom Link 6,dcim.site,100,blue,Link 6,http://exmaple.com/?6", + "name,content_type,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", ) cls.bulk_edit_data = { 'button_class': CustomLinkButtonClassChoices.CYAN, + 'enabled': False, 'weight': 200, } diff --git a/netbox/templates/extras/customlink.html b/netbox/templates/extras/customlink.html index ebf50882c9a..1f386618298 100644 --- a/netbox/templates/extras/customlink.html +++ b/netbox/templates/extras/customlink.html @@ -19,6 +19,10 @@ Content Type {{ object.content_type }} + + Enabled + {% checkmark object.enabled %} + Group Name {{ object.group_name|placeholder }} diff --git a/netbox/utilities/tables/tables.py b/netbox/utilities/tables/tables.py index 6c3b5695991..d1915569e7b 100644 --- a/netbox/utilities/tables/tables.py +++ b/netbox/utilities/tables/tables.py @@ -35,15 +35,16 @@ class BaseTable(tables.Table): if extra_columns is None: extra_columns = [] - # Add custom field columns - obj_type = ContentType.objects.get_for_model(self._meta.model) - cf_columns = [ - (f'cf_{cf.name}', columns.CustomFieldColumn(cf)) for cf in CustomField.objects.filter(content_types=obj_type) - ] - cl_columns = [ - (f'cl_{cl.name}', columns.CustomLinkColumn(cl)) for cl in CustomLink.objects.filter(content_type=obj_type) - ] - extra_columns.extend([*cf_columns, *cl_columns]) + # Add custom field & custom link columns + content_type = ContentType.objects.get_for_model(self._meta.model) + custom_fields = CustomField.objects.filter(content_types=content_type) + 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) + extra_columns.extend([ + (f'cl_{cl.name}', columns.CustomLinkColumn(cl)) for cl in custom_links + ]) super().__init__(*args, extra_columns=extra_columns, **kwargs)