diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index 5d0d72484..f167dee5c 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -173,12 +173,18 @@ class ConfigContextSerializer(ValidatedModelSerializer): required=False, many=True ) + tags = serializers.SlugRelatedField( + queryset=Tag.objects.all(), + slug_field='slug', + required=False, + many=True + ) class Meta: model = ConfigContext fields = [ 'id', 'name', 'weight', 'description', 'is_active', 'regions', 'sites', 'roles', 'platforms', - 'tenant_groups', 'tenants', 'data', + 'tenant_groups', 'tenants', 'tags', 'data', ] diff --git a/netbox/extras/filters.py b/netbox/extras/filters.py index b9bc013d1..001d0ce9e 100644 --- a/netbox/extras/filters.py +++ b/netbox/extras/filters.py @@ -5,7 +5,6 @@ from django.db.models import Q from dcim.models import DeviceRole, Platform, Region, Site from tenancy.models import Tenant, TenantGroup from .choices import * -from .constants import * from .models import ConfigContext, CustomField, Graph, ExportTemplate, ObjectChange, Tag @@ -180,6 +179,12 @@ class ConfigContextFilter(django_filters.FilterSet): to_field_name='slug', label='Tenant (slug)', ) + tag = django_filters.ModelMultipleChoiceFilter( + field_name='tags__slug', + queryset=Tag.objects.all(), + to_field_name='slug', + label='Tag (slug)', + ) class Meta: model = ConfigContext diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index b9f2d1538..c34b9225c 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -14,7 +14,6 @@ from utilities.forms import ( BOOLEAN_WITH_BLANK_CHOICES, ) from .choices import * -from .constants import * from .models import ConfigContext, CustomField, CustomFieldValue, ImageAttachment, ObjectChange, Tag @@ -238,6 +237,14 @@ class TagBulkEditForm(BootstrapMixin, BulkEditForm): # class ConfigContextForm(BootstrapMixin, forms.ModelForm): + tags = forms.ModelMultipleChoiceField( + queryset=Tag.objects.all(), + to_field_name='slug', + required=False, + widget=APISelectMultiple( + api_url="/api/extras/tags/" + ) + ) data = JSONField( label='' ) @@ -246,7 +253,7 @@ class ConfigContextForm(BootstrapMixin, forms.ModelForm): model = ConfigContext fields = [ 'name', 'weight', 'description', 'is_active', 'regions', 'sites', 'roles', 'platforms', 'tenant_groups', - 'tenants', 'data', + 'tenants', 'tags', 'data', ] widgets = { 'regions': APISelectMultiple( @@ -266,7 +273,7 @@ class ConfigContextForm(BootstrapMixin, forms.ModelForm): ), 'tenants': APISelectMultiple( api_url="/api/tenancy/tenants/" - ) + ), } @@ -347,6 +354,14 @@ class ConfigContextFilterForm(BootstrapMixin, forms.Form): value_field="slug", ) ) + tag = FilterChoiceField( + queryset=Tag.objects.all(), + to_field_name='slug', + widget=APISelectMultiple( + api_url="/api/extras/tags/", + value_field="slug", + ) + ) # diff --git a/netbox/extras/middleware.py b/netbox/extras/middleware.py index f305c1d56..3624e11a5 100644 --- a/netbox/extras/middleware.py +++ b/netbox/extras/middleware.py @@ -9,6 +9,7 @@ from django.db.models.signals import pre_delete, post_save from django.utils import timezone from django_prometheus.models import model_deletes, model_inserts, model_updates +from extras.utils import is_taggable from utilities.querysets import DummyQuerySet from .choices import ObjectChangeActionChoices from .models import ObjectChange @@ -41,7 +42,7 @@ def handle_deleted_object(sender, instance, **kwargs): copy = deepcopy(instance) # Preserve tags - if hasattr(instance, 'tags'): + if is_taggable(instance): copy.tags = DummyQuerySet(instance.tags.all()) # Queue the copy of the object for processing once the request completes diff --git a/netbox/extras/migrations/0034_configcontext_tags.py b/netbox/extras/migrations/0034_configcontext_tags.py new file mode 100644 index 000000000..363572535 --- /dev/null +++ b/netbox/extras/migrations/0034_configcontext_tags.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.6 on 2019-12-11 09:17 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0033_graph_type_to_fk'), + ] + + operations = [ + migrations.AddField( + model_name='configcontext', + name='tags', + field=models.ManyToManyField(blank=True, related_name='_configcontext_tags_+', to='extras.Tag'), + ), + ] diff --git a/netbox/extras/models.py b/netbox/extras/models.py index 09fd63772..23ce12ffb 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -682,6 +682,11 @@ class ConfigContext(models.Model): related_name='+', blank=True ) + tags = models.ManyToManyField( + to='extras.Tag', + related_name='+', + blank=True + ) data = JSONField() objects = ConfigContextQuerySet.as_manager() diff --git a/netbox/extras/querysets.py b/netbox/extras/querysets.py index 70c93968f..22ab489bd 100644 --- a/netbox/extras/querysets.py +++ b/netbox/extras/querysets.py @@ -46,5 +46,6 @@ class ConfigContextQuerySet(QuerySet): Q(platforms=obj.platform) | Q(platforms=None), Q(tenant_groups=tenant_group) | Q(tenant_groups=None), Q(tenants=obj.tenant) | Q(tenants=None), + Q(tags__slug__in=obj.tags.slugs()) | Q(tags=None), is_active=True, ).order_by('weight', 'name') diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py index ebefc8c50..df510aa17 100644 --- a/netbox/extras/tests/test_api.py +++ b/netbox/extras/tests/test_api.py @@ -370,6 +370,8 @@ class ConfigContextTest(APITestCase): tenantgroup2 = TenantGroup.objects.create(name='Test Tenant Group 2', slug='test-tenant-group-2') tenant1 = Tenant.objects.create(name='Test Tenant 1', slug='test-tenant-1') tenant2 = Tenant.objects.create(name='Test Tenant 2', slug='test-tenant-2') + tag1 = Tag.objects.create(name='Test Tag 1', slug='test-tag-1') + tag2 = Tag.objects.create(name='Test Tag 2', slug='test-tag-2') data = { 'name': 'Test Config Context 4', @@ -380,6 +382,7 @@ class ConfigContextTest(APITestCase): 'platforms': [platform1.pk, platform2.pk], 'tenant_groups': [tenantgroup1.pk, tenantgroup2.pk], 'tenants': [tenant1.pk, tenant2.pk], + 'tags': [tag1.slug, tag2.slug], 'data': {'foo': 'XXX'} } @@ -402,6 +405,8 @@ class ConfigContextTest(APITestCase): self.assertEqual(tenantgroup2.pk, data['tenant_groups'][1]) self.assertEqual(tenant1.pk, data['tenants'][0]) self.assertEqual(tenant2.pk, data['tenants'][1]) + self.assertEqual(tag1.slug, data['tags'][0]) + self.assertEqual(tag2.slug, data['tags'][1]) self.assertEqual(configcontext4.data, data['data']) def test_create_configcontext_bulk(self): diff --git a/netbox/extras/utils.py b/netbox/extras/utils.py new file mode 100644 index 000000000..ca3a72526 --- /dev/null +++ b/netbox/extras/utils.py @@ -0,0 +1,15 @@ +from taggit.managers import _TaggableManager +from utilities.querysets import DummyQuerySet + + +def is_taggable(obj): + """ + Return True if the instance can have Tags assigned to it; False otherwise. + """ + if hasattr(obj, 'tags'): + if issubclass(obj.tags.__class__, _TaggableManager): + return True + # TaggableManager has been replaced with a DummyQuerySet prior to object deletion + if isinstance(obj.tags, DummyQuerySet): + return True + return False diff --git a/netbox/templates/extras/configcontext.html b/netbox/templates/extras/configcontext.html index 3631122c3..353a91580 100644 --- a/netbox/templates/extras/configcontext.html +++ b/netbox/templates/extras/configcontext.html @@ -162,6 +162,20 @@ {% endif %} + + Tags + + {% if configcontext.tags.all %} + + {% else %} + None + {% endif %} + + diff --git a/netbox/templates/extras/configcontext_edit.html b/netbox/templates/extras/configcontext_edit.html index 7a3566a00..d31aa5c57 100644 --- a/netbox/templates/extras/configcontext_edit.html +++ b/netbox/templates/extras/configcontext_edit.html @@ -20,6 +20,7 @@ {% render_field form.platforms %} {% render_field form.tenant_groups %} {% render_field form.tenants %} + {% render_field form.tags %}
diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py index 4837cb5b4..da5d75dfc 100644 --- a/netbox/utilities/utils.py +++ b/netbox/utilities/utils.py @@ -6,6 +6,7 @@ from django.core.serializers import serialize from django.db.models import Count, OuterRef, Subquery from dcim.choices import CableLengthUnitChoices +from extras.utils import is_taggable def csv_format(data): @@ -103,7 +104,7 @@ def serialize_object(obj, extra=None): } # Include any tags - if hasattr(obj, 'tags'): + if is_taggable(obj): data['tags'] = [tag.name for tag in obj.tags.all()] # Append any extra data @@ -201,7 +202,7 @@ def prepare_cloned_fields(instance): params[field_name] = field_value # Copy tags - if hasattr(instance, 'tags'): + if is_taggable(instance): params['tags'] = ','.join([t.name for t in instance.tags.all()]) # Concatenate parameters into a URL query string diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index 5631a6ec6..90e28e718 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -24,6 +24,7 @@ from django_tables2 import RequestConfig from extras.models import CustomField, CustomFieldValue, ExportTemplate from extras.querysets import CustomFieldQueryset +from extras.utils import is_taggable from utilities.exceptions import AbortTransaction from utilities.forms import BootstrapMixin, CSVDataField from utilities.utils import csv_format, prepare_cloned_fields @@ -144,7 +145,7 @@ class ObjectListView(View): table.columns.show('pk') # Construct queryset for tags list - if hasattr(model, 'tags'): + if is_taggable(model): tags = model.tags.annotate(count=Count('extras_taggeditem_items')).order_by('name') else: tags = None