Sfoglia il codice sorgente

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 2 anni fa
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
 ### Color
 
 
 The color to use when displaying the tag in the NetBox UI.
 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):
 class TagSerializer(ValidatedModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='extras-api:tag-detail')
     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)
     tagged_items = serializers.IntegerField(read_only=True)
 
 
     class Meta:
     class Meta:
         model = Tag
         model = Tag
         fields = [
         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(
     content_type_id = MultiValueNumberFilter(
         method='_content_type_id'
         method='_content_type_id'
     )
     )
+    for_object_type_id = MultiValueNumberFilter(
+        method='_for_object_type'
+    )
 
 
     class Meta:
     class Meta:
         model = Tag
         model = Tag
-        fields = ['id', 'name', 'slug', 'color', 'description']
+        fields = ['id', 'name', 'slug', 'color', 'description', 'object_types']
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
@@ -298,6 +301,11 @@ class TagFilterSet(ChangeLoggedModelFilterSet):
 
 
         return queryset.filter(extras_taggeditem_items__content_type__in=content_types).distinct()
         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):
 class ConfigContextFilterSet(ChangeLoggedModelFilterSet):
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(

+ 5 - 0
netbox/extras/forms/filtersets.py

@@ -245,6 +245,11 @@ class TagFilterForm(SavedFiltersMixin, FilterForm):
         required=False,
         required=False,
         label=_('Tagged object type')
         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):
 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):
 class TagForm(BootstrapMixin, forms.ModelForm):
     slug = SlugField()
     slug = SlugField()
+    object_types = ContentTypeMultipleChoiceField(
+        queryset=ContentType.objects.all(),
+        limit_choices_to=FeatureQuery('tags'),
+        required=False
+    )
 
 
     fieldsets = (
     fieldsets = (
-        ('Tag', ('name', 'slug', 'color', 'description')),
+        ('Tag', ('name', 'slug', 'color', 'description', 'object_types')),
     )
     )
 
 
     class Meta:
     class Meta:
         model = Tag
         model = Tag
         fields = [
         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.conf import settings
+from django.contrib.contenttypes.models import ContentType
+from django.core.exceptions import ValidationError
 from django.db import models
 from django.db import models
 from django.urls import reverse
 from django.urls import reverse
 from django.utils.text import slugify
 from django.utils.text import slugify
+from django.utils.translation import gettext as _
 from taggit.models import TagBase, GenericTaggedItemBase
 from taggit.models import TagBase, GenericTaggedItemBase
 
 
+from extras.utils import FeatureQuery
 from netbox.models import ChangeLoggedModel
 from netbox.models import ChangeLoggedModel
 from netbox.models.features import CloningMixin, ExportTemplatesMixin
 from netbox.models.features import CloningMixin, ExportTemplatesMixin
 from utilities.choices import ColorChoices
 from utilities.choices import ColorChoices
@@ -30,9 +34,16 @@ class Tag(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel, TagBase):
         max_length=200,
         max_length=200,
         blank=True,
         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 = (
     clone_fields = (
-        'color', 'description',
+        'color', 'description', 'object_types',
     )
     )
 
 
     class Meta:
     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.config import get_config
 from netbox.context import current_request, webhooks_queue
 from netbox.context import current_request, webhooks_queue
 from netbox.signals import post_clean
 from netbox.signals import post_clean
+from utilities.exceptions import AbortRequest
 from .choices import ObjectChangeActionChoices
 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
 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.
     Update the cached NetBox configuration when a new ConfigRevision is created.
     """
     """
     instance.activate()
     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
         linkify=True
     )
     )
     color = columns.ColorColumn()
     color = columns.ColorColumn()
+    object_types = columns.ContentTypesColumn()
 
 
     class Meta(NetBoxTable.Meta):
     class Meta(NetBoxTable.Meta):
         model = Tag
         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')
         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
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
+        content_types = {
+            'site': ContentType.objects.get_by_natural_key('dcim', 'site'),
+            'provider': ContentType.objects.get_by_natural_key('circuits', 'provider'),
+        }
 
 
         tags = (
         tags = (
             Tag(name='Tag 1', slug='tag-1', color='ff0000', description='foobar1'),
             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(name='Tag 3', slug='tag-3', color='0000ff'),
         )
         )
         Tag.objects.bulk_create(tags)
         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
         # Apply some tags so we can filter by content type
         site = Site.objects.create(name='Site 1', slug='site-1')
         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]}
         params = {'content_type_id': [site_ct, provider_ct]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         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):
 class ObjectChangeTestCase(TestCase, BaseFilterSetTests):
     queryset = ObjectChange.objects.all()
     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 django.test import TestCase
 
 
 from dcim.models import Device, DeviceRole, DeviceType, Location, Manufacturer, Platform, Region, Site, SiteGroup
 from dcim.models import Device, DeviceRole, DeviceType, Location, Manufacturer, Platform, Region, Site, SiteGroup
 from extras.models import ConfigContext, Tag
 from extras.models import ConfigContext, Tag
 from tenancy.models import Tenant, TenantGroup
 from tenancy.models import Tenant, TenantGroup
+from utilities.exceptions import AbortRequest
 from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
 from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
 
 
 
 
@@ -14,6 +16,22 @@ class TagTest(TestCase):
 
 
         self.assertEqual(tag.slug, 'testing-unicode-台灣')
         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):
 class ConfigContextTest(TestCase):
     """
     """

+ 7 - 0
netbox/netbox/forms/base.py

@@ -31,6 +31,13 @@ class NetBoxModelForm(BootstrapMixin, CustomFieldsMixin, forms.ModelForm):
         required=False
         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):
     def _get_content_type(self):
         return ContentType.objects.get_for_model(self._meta.model)
         return ContentType.objects.get_for_model(self._meta.model)
 
 

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

@@ -43,9 +43,23 @@
     </div>
     </div>
     <div class="col col-md-6">
     <div class="col col-md-6">
       <div class="card">
       <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">
         <div class="card-body">
           <table class="table table-hover panel-body attr-table">
           <table class="table table-hover panel-body attr-table">
             {% for object_type in object_types %}
             {% for object_type in object_types %}