Closes #8296: Allow disabling custom links

This commit is contained in:
jeremystretch 2022-01-10 12:11:37 -05:00
parent 17aa37ae21
commit 72e17914e2
17 changed files with 88 additions and 27 deletions

View File

@ -15,7 +15,7 @@ When viewing a device named Router4, this link would render as:
<a href="https://nms.example.com/nodes/?name=Router4">View NMS</a> <a href="https://nms.example.com/nodes/?name=Router4">View NMS</a>
``` ```
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 !!! 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. 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.

View File

@ -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 * [#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 * [#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 * [#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 ### Other Changes
@ -106,6 +107,8 @@ Inventory item templates can be arranged hierarchically within a device type, an
* Add `cluster_types` field * Add `cluster_types` field
* extras.CustomField * extras.CustomField
* Added `object_type` field * Added `object_type` field
* extras.CustomLink
* Added `enabled` field
* ipam.VLANGroup * ipam.VLANGroup
* Added the `/availables-vlans/` endpoint * Added the `/availables-vlans/` endpoint
* Added the `min_vid` and `max_vid` fields * Added the `min_vid` and `max_vid` fields

View File

@ -101,7 +101,7 @@ class CustomLinkSerializer(ValidatedModelSerializer):
class Meta: class Meta:
model = CustomLink model = CustomLink
fields = [ 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', 'button_class', 'new_window',
] ]

View File

@ -82,7 +82,9 @@ class CustomLinkFilterSet(BaseFilterSet):
class Meta: class Meta:
model = CustomLink 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): def search(self, queryset, name, value):
if not value.strip(): if not value.strip():

View File

@ -47,6 +47,10 @@ class CustomLinkBulkEditForm(BulkEditForm):
limit_choices_to=FeatureQuery('custom_fields'), limit_choices_to=FeatureQuery('custom_fields'),
required=False required=False
) )
enabled = forms.NullBooleanField(
required=False,
widget=BulkEditNullBooleanSelect()
)
new_window = forms.NullBooleanField( new_window = forms.NullBooleanField(
required=False, required=False,
widget=BulkEditNullBooleanSelect() widget=BulkEditNullBooleanSelect()

View File

@ -51,7 +51,8 @@ class CustomLinkCSVForm(CSVModelForm):
class Meta: class Meta:
model = CustomLink model = CustomLink
fields = ( 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',
) )

View File

@ -58,15 +58,18 @@ class CustomFieldFilterForm(FilterForm):
class CustomLinkFilterForm(FilterForm): class CustomLinkFilterForm(FilterForm):
field_groups = [ field_groups = [
['q'], ['q'],
['content_type', 'weight', 'new_window'], ['content_type', 'enabled', 'new_window', 'weight'],
] ]
content_type = ContentTypeChoiceField( content_type = ContentTypeChoiceField(
queryset=ContentType.objects.all(), queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('custom_fields'), limit_choices_to=FeatureQuery('custom_fields'),
required=False required=False
) )
weight = forms.IntegerField( enabled = forms.NullBooleanField(
required=False required=False,
widget=StaticSelect(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
) )
new_window = forms.NullBooleanField( new_window = forms.NullBooleanField(
required=False, required=False,
@ -74,6 +77,9 @@ class CustomLinkFilterForm(FilterForm):
choices=BOOLEAN_WITH_BLANK_CHOICES choices=BOOLEAN_WITH_BLANK_CHOICES
) )
) )
weight = forms.IntegerField(
required=False
)
class ExportTemplateFilterForm(FilterForm): class ExportTemplateFilterForm(FilterForm):

View File

@ -53,7 +53,7 @@ class CustomLinkForm(BootstrapMixin, forms.ModelForm):
model = CustomLink model = CustomLink
fields = '__all__' fields = '__all__'
fieldsets = ( 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')), ('Templates', ('link_text', 'link_url')),
) )
widgets = { widgets = {

View File

@ -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),
),
]

View File

@ -192,6 +192,9 @@ class CustomLink(ChangeLoggedModel):
max_length=100, max_length=100,
unique=True unique=True
) )
enabled = models.BooleanField(
default=True
)
link_text = models.CharField( link_text = models.CharField(
max_length=500, max_length=500,
help_text="Jinja2 template code for link text" help_text="Jinja2 template code for link text"

View File

@ -73,15 +73,16 @@ class CustomLinkTable(BaseTable):
linkify=True linkify=True
) )
content_type = ContentTypeColumn() content_type = ContentTypeColumn()
enabled = BooleanColumn()
new_window = BooleanColumn() new_window = BooleanColumn()
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = CustomLink model = CustomLink
fields = ( 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', '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')
# #

View File

@ -36,7 +36,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) custom_links = CustomLink.objects.filter(content_type=content_type, enabled=True)
if not custom_links: if not custom_links:
return '' return ''

View File

@ -139,24 +139,28 @@ class CustomLinkTest(APIViewTestCases.APIViewTestCase):
{ {
'content_type': 'dcim.site', 'content_type': 'dcim.site',
'name': 'Custom Link 4', 'name': 'Custom Link 4',
'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_type': 'dcim.site',
'name': 'Custom Link 5', 'name': 'Custom Link 5',
'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_type': 'dcim.site',
'name': 'Custom Link 6', 'name': 'Custom Link 6',
'enabled': False,
'link_text': 'Link 6', 'link_text': 'Link 6',
'link_url': 'http://example.com/?6', 'link_url': 'http://example.com/?6',
}, },
] ]
bulk_update_data = { bulk_update_data = {
'new_window': True, 'new_window': True,
'enabled': False,
} }
@classmethod @classmethod
@ -167,18 +171,21 @@ class CustomLinkTest(APIViewTestCases.APIViewTestCase):
CustomLink( CustomLink(
content_type=site_ct, content_type=site_ct,
name='Custom Link 1', name='Custom Link 1',
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, content_type=site_ct,
name='Custom Link 2', name='Custom Link 2',
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, content_type=site_ct,
name='Custom Link 3', name='Custom Link 3',
enabled=False,
link_text='Link 3', link_text='Link 3',
link_url='http://example.com/?3', link_url='http://example.com/?3',
), ),

View File

@ -100,6 +100,7 @@ class CustomLinkTestCase(TestCase, BaseFilterSetTests):
CustomLink( CustomLink(
name='Custom Link 1', name='Custom Link 1',
content_type=content_types[0], content_type=content_types[0],
enabled=True,
weight=100, weight=100,
new_window=False, new_window=False,
link_text='Link 1', link_text='Link 1',
@ -108,6 +109,7 @@ class CustomLinkTestCase(TestCase, BaseFilterSetTests):
CustomLink( CustomLink(
name='Custom Link 2', name='Custom Link 2',
content_type=content_types[1], content_type=content_types[1],
enabled=True,
weight=200, weight=200,
new_window=False, new_window=False,
link_text='Link 1', link_text='Link 1',
@ -116,6 +118,7 @@ class CustomLinkTestCase(TestCase, BaseFilterSetTests):
CustomLink( CustomLink(
name='Custom Link 3', name='Custom Link 3',
content_type=content_types[2], content_type=content_types[2],
enabled=False,
weight=300, weight=300,
new_window=True, new_window=True,
link_text='Link 1', link_text='Link 1',
@ -136,6 +139,12 @@ class CustomLinkTestCase(TestCase, BaseFilterSetTests):
params = {'weight': [100, 200]} params = {'weight': [100, 200]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) 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): def test_new_window(self):
params = {'new_window': False} params = {'new_window': False}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)

View File

@ -59,14 +59,15 @@ class CustomLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase):
site_ct = ContentType.objects.get_for_model(Site) site_ct = ContentType.objects.get_for_model(Site)
CustomLink.objects.bulk_create(( 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 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, link_text='Link 2', link_url='http://example.com/?2'), 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, link_text='Link 3', link_url='http://example.com/?3'), CustomLink(name='Custom Link 3', content_type=site_ct, enabled=False, link_text='Link 3', link_url='http://example.com/?3'),
)) ))
cls.form_data = { cls.form_data = {
'name': 'Custom Link X', 'name': 'Custom Link X',
'content_type': site_ct.pk, 'content_type': site_ct.pk,
'enabled': False,
'weight': 100, 'weight': 100,
'button_class': CustomLinkButtonClassChoices.DEFAULT, 'button_class': CustomLinkButtonClassChoices.DEFAULT,
'link_text': 'Link X', 'link_text': 'Link X',
@ -74,14 +75,15 @@ class CustomLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase):
} }
cls.csv_data = ( cls.csv_data = (
"name,content_type,weight,button_class,link_text,link_url", "name,content_type,enabled,weight,button_class,link_text,link_url",
"Custom Link 4,dcim.site,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,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,100,blue,Link 6,http://exmaple.com/?6", "Custom Link 6,dcim.site,False,100,blue,Link 6,http://exmaple.com/?6",
) )
cls.bulk_edit_data = { cls.bulk_edit_data = {
'button_class': CustomLinkButtonClassChoices.CYAN, 'button_class': CustomLinkButtonClassChoices.CYAN,
'enabled': False,
'weight': 200, 'weight': 200,
} }

View File

@ -19,6 +19,10 @@
<th scope="row">Content Type</th> <th scope="row">Content Type</th>
<td>{{ object.content_type }}</td> <td>{{ object.content_type }}</td>
</tr> </tr>
<tr>
<th scope="row">Enabled</th>
<td>{% checkmark object.enabled %}</td>
</tr>
<tr> <tr>
<th scope="row">Group Name</th> <th scope="row">Group Name</th>
<td>{{ object.group_name|placeholder }}</td> <td>{{ object.group_name|placeholder }}</td>

View File

@ -35,15 +35,16 @@ class BaseTable(tables.Table):
if extra_columns is None: if extra_columns is None:
extra_columns = [] extra_columns = []
# Add custom field columns # Add custom field & custom link columns
obj_type = ContentType.objects.get_for_model(self._meta.model) content_type = ContentType.objects.get_for_model(self._meta.model)
cf_columns = [ custom_fields = CustomField.objects.filter(content_types=content_type)
(f'cf_{cf.name}', columns.CustomFieldColumn(cf)) for cf in CustomField.objects.filter(content_types=obj_type) extra_columns.extend([
] (f'cf_{cf.name}', columns.CustomFieldColumn(cf)) for cf in custom_fields
cl_columns = [ ])
(f'cl_{cl.name}', columns.CustomLinkColumn(cl)) for cl in CustomLink.objects.filter(content_type=obj_type) custom_links = CustomLink.objects.filter(content_type=content_type, enabled=True)
] extra_columns.extend([
extra_columns.extend([*cf_columns, *cl_columns]) (f'cl_{cl.name}', columns.CustomLinkColumn(cl)) for cl in custom_links
])
super().__init__(*args, extra_columns=extra_columns, **kwargs) super().__init__(*args, extra_columns=extra_columns, **kwargs)