Parcourir la source

Closes #11541: Support for limiting tag assignments by object type (#12982)

* Initial work on #11541

* Merge migrations

* Limit tags by object type during assignment

* Add tests for object type validation

* Fix form field parameters
Jeremy Stretch il y a 2 ans
Parent
commit
1056e513b1

+ 8 - 0
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.

+ 7 - 1
netbox/extras/api/serializers.py

@@ -196,12 +196,18 @@ class SavedFilterSerializer(ValidatedModelSerializer):
 
 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',
         ]
 
 

+ 9 - 1
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 @@ class TagFilterSet(ChangeLoggedModelFilterSet):
 
         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(

+ 5 - 0
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):

+ 7 - 2
netbox/extras/forms/model_forms.py

@@ -204,15 +204,20 @@ class WebhookForm(BootstrapMixin, forms.ModelForm):
 
 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',
         ]
 
 

+ 0 - 17
netbox/extras/migrations/0093_tagged_item_indexes.py

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

+ 23 - 0
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'),
+        ),
+    ]

+ 12 - 1
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:

+ 20 - 1
netbox/extras/signals.py

@@ -10,8 +10,9 @@ from extras.validators import CustomValidator
 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.")

+ 5 - 1
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')
 
 

+ 18 - 0
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 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
             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 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
         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()

+ 18 - 0
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 @@ class TagTest(TestCase):
 
         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):
     """

+ 7 - 0
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)
 

+ 17 - 3
netbox/templates/extras/tag.html

@@ -43,9 +43,23 @@
     </div>
     <div class="col col-md-6">
       <div class="card">
-        <h5 class="card-header">
-          Tagged Item Types
-        </h5>
+        <h5 class="card-header">Allowed Object Types</h5>
+        <div class="card-body">
+          <table class="table table-hover attr-table">
+            {% for ct in object.object_types.all %}
+              <tr>
+                <td>{{ ct }}</td>
+              </tr>
+            {% empty %}
+              <tr>
+                <td class="text-muted">Any</td>
+              </tr>
+            {% endfor %}
+          </table>
+        </div>
+      </div>
+      <div class="card">
+        <h5 class="card-header">Tagged Item Types</h5>
         <div class="card-body">
           <table class="table table-hover panel-body attr-table">
             {% for object_type in object_types %}