diff --git a/docs/models/extras/tag.md b/docs/models/extras/tag.md index 97ebd9d723..684be582e3 100644 --- a/docs/models/extras/tag.md +++ b/docs/models/extras/tag.md @@ -15,3 +15,11 @@ A unique URL-friendly identifier. (This value will be used for filtering.) This ### Color The color to use when displaying the tag in the NetBox UI. + +### Object Types + +!!! info "This feature was introduced in NetBox v3.6." + +The assignment of a tag may be limited to a prescribed set of objects. For example, it may be desirable to limit the application of a specific tag to only devices and virtual machines. + +If no object types are specified, the tag will be assignable to any type of object. diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index a02e933bae..c71e840d55 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -196,12 +196,18 @@ class Meta: class TagSerializer(ValidatedModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='extras-api:tag-detail') + object_types = ContentTypeField( + queryset=ContentType.objects.filter(FeatureQuery('custom_fields').get_query()), + many=True, + required=False + ) tagged_items = serializers.IntegerField(read_only=True) class Meta: model = Tag fields = [ - 'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'tagged_items', 'created', 'last_updated', + 'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'object_types', 'tagged_items', 'created', + 'last_updated', ] diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index 6120e2a515..acb0aa359b 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -258,10 +258,13 @@ class TagFilterSet(ChangeLoggedModelFilterSet): content_type_id = MultiValueNumberFilter( method='_content_type_id' ) + for_object_type_id = MultiValueNumberFilter( + method='_for_object_type' + ) class Meta: model = Tag - fields = ['id', 'name', 'slug', 'color', 'description'] + fields = ['id', 'name', 'slug', 'color', 'description', 'object_types'] def search(self, queryset, name, value): if not value.strip(): @@ -298,6 +301,11 @@ def _content_type_id(self, queryset, name, values): return queryset.filter(extras_taggeditem_items__content_type__in=content_types).distinct() + def _for_object_type(self, queryset, name, values): + return queryset.filter( + Q(object_types__id__in=values) | Q(object_types__isnull=True) + ) + class ConfigContextFilterSet(ChangeLoggedModelFilterSet): q = django_filters.CharFilter( diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py index bcf2e7863c..56e9c8dfb5 100644 --- a/netbox/extras/forms/filtersets.py +++ b/netbox/extras/forms/filtersets.py @@ -245,6 +245,11 @@ class TagFilterForm(SavedFiltersMixin, FilterForm): required=False, label=_('Tagged object type') ) + for_object_type_id = ContentTypeChoiceField( + queryset=ContentType.objects.filter(FeatureQuery('tags').get_query()), + required=False, + label=_('Allowed object type') + ) class ConfigContextFilterForm(SavedFiltersMixin, FilterForm): diff --git a/netbox/extras/forms/model_forms.py b/netbox/extras/forms/model_forms.py index 621052c96f..f8aa982bc2 100644 --- a/netbox/extras/forms/model_forms.py +++ b/netbox/extras/forms/model_forms.py @@ -204,15 +204,20 @@ class Meta: class TagForm(BootstrapMixin, forms.ModelForm): slug = SlugField() + object_types = ContentTypeMultipleChoiceField( + queryset=ContentType.objects.all(), + limit_choices_to=FeatureQuery('tags'), + required=False + ) fieldsets = ( - ('Tag', ('name', 'slug', 'color', 'description')), + ('Tag', ('name', 'slug', 'color', 'description', 'object_types')), ) class Meta: model = Tag fields = [ - 'name', 'slug', 'color', 'description' + 'name', 'slug', 'color', 'description', 'object_types', ] diff --git a/netbox/extras/migrations/0093_tagged_item_indexes.py b/netbox/extras/migrations/0093_tagged_item_indexes.py deleted file mode 100644 index 6e24e362b8..0000000000 --- a/netbox/extras/migrations/0093_tagged_item_indexes.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 4.2.2 on 2023-06-14 23:26 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ('extras', '0093_configrevision_ordering'), - ] - - operations = [ - migrations.RenameIndex( - model_name='taggeditem', - new_name='extras_tagg_content_717743_idx', - old_fields=('content_type', 'object_id'), - ), - ] diff --git a/netbox/extras/migrations/0094_tag_object_types.py b/netbox/extras/migrations/0094_tag_object_types.py new file mode 100644 index 0000000000..944ef64b28 --- /dev/null +++ b/netbox/extras/migrations/0094_tag_object_types.py @@ -0,0 +1,23 @@ +from django.db import migrations, models +import extras.utils + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('extras', '0093_configrevision_ordering'), + ] + + operations = [ + migrations.AddField( + model_name='tag', + name='object_types', + field=models.ManyToManyField(blank=True, limit_choices_to=extras.utils.FeatureQuery('tags'), related_name='+', to='contenttypes.contenttype'), + ), + migrations.RenameIndex( + model_name='taggeditem', + new_name='extras_tagg_content_717743_idx', + old_fields=('content_type', 'object_id'), + ), + ] diff --git a/netbox/extras/models/tags.py b/netbox/extras/models/tags.py index 31128ad541..f54b3d0fef 100644 --- a/netbox/extras/models/tags.py +++ b/netbox/extras/models/tags.py @@ -1,9 +1,13 @@ from django.conf import settings +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ValidationError from django.db import models from django.urls import reverse from django.utils.text import slugify +from django.utils.translation import gettext as _ from taggit.models import TagBase, GenericTaggedItemBase +from extras.utils import FeatureQuery from netbox.models import ChangeLoggedModel from netbox.models.features import CloningMixin, ExportTemplatesMixin from utilities.choices import ColorChoices @@ -30,9 +34,16 @@ class Tag(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel, TagBase): max_length=200, blank=True, ) + object_types = models.ManyToManyField( + to=ContentType, + related_name='+', + limit_choices_to=FeatureQuery('tags'), + blank=True, + help_text=_("The object type(s) to which this this tag can be applied.") + ) clone_fields = ( - 'color', 'description', + 'color', 'description', 'object_types', ) class Meta: diff --git a/netbox/extras/signals.py b/netbox/extras/signals.py index 4972d9e853..d6550309f4 100644 --- a/netbox/extras/signals.py +++ b/netbox/extras/signals.py @@ -10,8 +10,9 @@ from netbox.config import get_config from netbox.context import current_request, webhooks_queue from netbox.signals import post_clean +from utilities.exceptions import AbortRequest from .choices import ObjectChangeActionChoices -from .models import ConfigRevision, CustomField, ObjectChange +from .models import ConfigRevision, CustomField, ObjectChange, TaggedItem from .webhooks import enqueue_object, get_snapshots, serialize_for_webhook # @@ -207,3 +208,21 @@ def update_config(sender, instance, **kwargs): Update the cached NetBox configuration when a new ConfigRevision is created. """ instance.activate() + + +# +# Tags +# + +@receiver(m2m_changed, sender=TaggedItem) +def validate_assigned_tags(sender, instance, action, model, pk_set, **kwargs): + """ + Validate that any Tags being assigned to the instance are not restricted to non-applicable object types. + """ + if action != 'pre_add': + return + ct = ContentType.objects.get_for_model(instance) + # Retrieve any applied Tags that are restricted to certain object_types + for tag in model.objects.filter(pk__in=pk_set, object_types__isnull=False).prefetch_related('object_types'): + if ct not in tag.object_types.all(): + raise AbortRequest(f"Tag {tag} cannot be assigned to {ct.model} objects.") diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py index e41bc91264..35d53d1a6c 100644 --- a/netbox/extras/tables/tables.py +++ b/netbox/extras/tables/tables.py @@ -210,10 +210,14 @@ class TagTable(NetBoxTable): linkify=True ) color = columns.ColorColumn() + object_types = columns.ContentTypesColumn() class Meta(NetBoxTable.Meta): model = Tag - fields = ('pk', 'id', 'name', 'items', 'slug', 'color', 'description', 'created', 'last_updated', 'actions') + fields = ( + 'pk', 'id', 'name', 'items', 'slug', 'color', 'description', 'object_types', 'created', 'last_updated', + 'actions', + ) default_columns = ('pk', 'name', 'items', 'slug', 'color', 'description') diff --git a/netbox/extras/tests/test_filtersets.py b/netbox/extras/tests/test_filtersets.py index 9926435305..7dff14cc05 100644 --- a/netbox/extras/tests/test_filtersets.py +++ b/netbox/extras/tests/test_filtersets.py @@ -821,6 +821,10 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests): @classmethod def setUpTestData(cls): + content_types = { + 'site': ContentType.objects.get_by_natural_key('dcim', 'site'), + 'provider': ContentType.objects.get_by_natural_key('circuits', 'provider'), + } tags = ( Tag(name='Tag 1', slug='tag-1', color='ff0000', description='foobar1'), @@ -828,6 +832,8 @@ def setUpTestData(cls): Tag(name='Tag 3', slug='tag-3', color='0000ff'), ) Tag.objects.bulk_create(tags) + tags[0].object_types.add(content_types['site']) + tags[1].object_types.add(content_types['provider']) # Apply some tags so we can filter by content type site = Site.objects.create(name='Site 1', slug='site-1') @@ -860,6 +866,18 @@ def test_content_type(self): params = {'content_type_id': [site_ct, provider_ct]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_object_types(self): + params = {'for_object_type_id': [ContentType.objects.get_by_natural_key('dcim', 'site').pk]} + self.assertEqual( + list(self.filterset(params, self.queryset).qs.values_list('name', flat=True)), + ['Tag 1', 'Tag 3'] + ) + params = {'for_object_type_id': [ContentType.objects.get_by_natural_key('circuits', 'provider').pk]} + self.assertEqual( + list(self.filterset(params, self.queryset).qs.values_list('name', flat=True)), + ['Tag 2', 'Tag 3'] + ) + class ObjectChangeTestCase(TestCase, BaseFilterSetTests): queryset = ObjectChange.objects.all() diff --git a/netbox/extras/tests/test_models.py b/netbox/extras/tests/test_models.py index 0ac63c086b..0d1dc0e518 100644 --- a/netbox/extras/tests/test_models.py +++ b/netbox/extras/tests/test_models.py @@ -1,8 +1,10 @@ +from django.contrib.contenttypes.models import ContentType from django.test import TestCase from dcim.models import Device, DeviceRole, DeviceType, Location, Manufacturer, Platform, Region, Site, SiteGroup from extras.models import ConfigContext, Tag from tenancy.models import Tenant, TenantGroup +from utilities.exceptions import AbortRequest from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine @@ -14,6 +16,22 @@ def test_create_tag_unicode(self): self.assertEqual(tag.slug, 'testing-unicode-台灣') + def test_object_type_validation(self): + region = Region.objects.create(name='Region 1', slug='region-1') + sitegroup = SiteGroup.objects.create(name='Site Group 1', slug='site-group-1') + + # Create a Tag that can only be applied to Regions + tag = Tag.objects.create(name='Tag 1', slug='tag-1') + tag.object_types.add(ContentType.objects.get_by_natural_key('dcim', 'region')) + + # Apply the Tag to a Region + region.tags.add(tag) + self.assertIn(tag, region.tags.all()) + + # Apply the Tag to a SiteGroup + with self.assertRaises(AbortRequest): + sitegroup.tags.add(tag) + class ConfigContextTest(TestCase): """ diff --git a/netbox/netbox/forms/base.py b/netbox/netbox/forms/base.py index 83c238e0f3..88cec405fc 100644 --- a/netbox/netbox/forms/base.py +++ b/netbox/netbox/forms/base.py @@ -31,6 +31,13 @@ class NetBoxModelForm(BootstrapMixin, CustomFieldsMixin, forms.ModelForm): required=False ) + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Limit tags to those applicable to the object type + if (ct := self._get_content_type()) and hasattr(self.fields['tags'].widget, 'add_query_param'): + self.fields['tags'].widget.add_query_param('for_object_type_id', ct.pk) + def _get_content_type(self): return ContentType.objects.get_for_model(self._meta.model) diff --git a/netbox/templates/extras/tag.html b/netbox/templates/extras/tag.html index 6e4c5aee96..e5aa5cc75d 100644 --- a/netbox/templates/extras/tag.html +++ b/netbox/templates/extras/tag.html @@ -43,9 +43,23 @@
-
- Tagged Item Types -
+
Allowed Object Types
+
+ + {% for ct in object.object_types.all %} + + + + {% empty %} + + + + {% endfor %} +
{{ ct }}
Any
+
+
+
+
Tagged Item Types
{% for object_type in object_types %}