mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-15 11:42:52 -06:00
* 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:
parent
69b818ed33
commit
1056e513b1
@ -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.
|
||||||
|
@ -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',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@ -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(
|
||||||
|
@ -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):
|
||||||
|
@ -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',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@ -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
netbox/extras/migrations/0094_tag_object_types.py
Normal file
23
netbox/extras/migrations/0094_tag_object_types.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
@ -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:
|
||||||
|
@ -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.")
|
||||||
|
@ -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')
|
||||||
|
|
||||||
|
|
||||||
|
@ -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()
|
||||||
|
@ -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):
|
||||||
"""
|
"""
|
||||||
|
@ -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)
|
||||||
|
|
||||||
|
@ -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">
|
<h5 class="card-header">Allowed Object Types</h5>
|
||||||
Tagged Item Types
|
<div class="card-body">
|
||||||
</h5>
|
<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 %}
|
||||||
|
Loading…
Reference in New Issue
Block a user