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
This commit is contained in:
Jeremy Stretch 2023-06-23 14:08:14 -04:00 committed by GitHub
parent 69b818ed33
commit 1056e513b1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 156 additions and 27 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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.")

View File

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

View File

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

View File

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

View File

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

View File

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