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 @@
-
+
Name |
{{ object.name }} |
-
- Content Type |
- {{ object.content_type }} |
-
Enabled |
{% checkmark object.enabled %} |
@@ -42,6 +36,18 @@
+
+
+
+
+ {% for ct in object.content_types.all %}
+
+ {{ ct }} |
+
+ {% endfor %}
+
+
+
{% plugin_left_page object %}