Merge pull request #10760 from netbox-community/8274-customlink-content-types

Closes #8274: Enable associating a custom link with multiple object types
This commit is contained in:
Jeremy Stretch 2022-10-26 12:22:56 -04:00 committed by GitHub
commit 7a155407f6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 94 additions and 51 deletions

View File

@ -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

View File

@ -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( content_types = ContentTypeField(
queryset=ContentType.objects.filter(FeatureQuery('custom_links').get_query()) 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',
] ]

View File

@ -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):

View File

@ -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()

View File

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

View File

@ -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

View File

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

View File

@ -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

View File

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

View File

@ -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, related_name='custom_links',
limit_choices_to=FeatureQuery('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:

View File

@ -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 ''

View File

@ -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):

View File

@ -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): def test_content_types(self):
params = {'content_type': ContentType.objects.get(model='site').pk} 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):

View File

@ -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(( custom_links = (
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 1', 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 2', 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'), 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()

View File

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

View File

@ -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"> <h5 class="card-header">Custom Link</h5>
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">