Skip to content

Commit

Permalink
Closes #11541: Support for limiting tag assignments by object type (#…
Browse files Browse the repository at this point in the history
…12982)

* Initial work on #11541

* Merge migrations

* Limit tags by object type during assignment

* Add tests for object type validation

* Fix form field parameters
  • Loading branch information
jeremystretch authored Jun 23, 2023
1 parent 69b818e commit 1056e51
Show file tree
Hide file tree
Showing 14 changed files with 156 additions and 27 deletions.
8 changes: 8 additions & 0 deletions docs/models/extras/tag.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
8 changes: 7 additions & 1 deletion netbox/extras/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
]


Expand Down
10 changes: 9 additions & 1 deletion netbox/extras/filtersets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down Expand Up @@ -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(
Expand Down
5 changes: 5 additions & 0 deletions netbox/extras/forms/filtersets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
9 changes: 7 additions & 2 deletions netbox/extras/forms/model_forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
]


Expand Down
17 changes: 0 additions & 17 deletions netbox/extras/migrations/0093_tagged_item_indexes.py

This file was deleted.

23 changes: 23 additions & 0 deletions netbox/extras/migrations/0094_tag_object_types.py
Original file line number Diff line number Diff line change
@@ -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'),
),
]
13 changes: 12 additions & 1 deletion netbox/extras/models/tags.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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:
Expand Down
21 changes: 20 additions & 1 deletion netbox/extras/signals.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

#
Expand Down Expand Up @@ -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.")
6 changes: 5 additions & 1 deletion netbox/extras/tables/tables.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')


Expand Down
18 changes: 18 additions & 0 deletions netbox/extras/tests/test_filtersets.py
Original file line number Diff line number Diff line change
Expand Up @@ -821,13 +821,19 @@ 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'),
Tag(name='Tag 2', slug='tag-2', color='00ff00', description='foobar2'),
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')
Expand Down Expand Up @@ -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()
Expand Down
18 changes: 18 additions & 0 deletions netbox/extras/tests/test_models.py
Original file line number Diff line number Diff line change
@@ -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


Expand All @@ -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):
"""
Expand Down
7 changes: 7 additions & 0 deletions netbox/netbox/forms/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
20 changes: 17 additions & 3 deletions netbox/templates/extras/tag.html
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,23 @@ <h5 class="card-header">
</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 %}
Expand Down

0 comments on commit 1056e51

Please sign in to comment.