From 5710f297f1f8ccc1d29363812b2816b6146851f7 Mon Sep 17 00:00:00 2001 From: kobayashi Date: Wed, 11 Dec 2019 04:48:50 -0500 Subject: [PATCH 1/4] implement 3664 --- netbox/extras/api/serializers.py | 8 +++++++- netbox/extras/filters.py | 11 +++++++++++ netbox/extras/forms.py | 13 ++++++++++++- .../migrations/0034_configcontext_tags.py | 18 ++++++++++++++++++ netbox/extras/models.py | 5 +++++ netbox/extras/querysets.py | 3 +++ netbox/extras/tests/test_api.py | 5 +++++ netbox/templates/extras/configcontext.html | 14 ++++++++++++++ .../templates/extras/configcontext_edit.html | 1 + netbox/utilities/views.py | 2 +- 10 files changed, 77 insertions(+), 3 deletions(-) create mode 100644 netbox/extras/migrations/0034_configcontext_tags.py diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index 5d0d72484..6872bab72 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 = SerializedPKRelatedField( + queryset=Tag.objects.all(), + serializer=TagSerializer, + 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..52b2776b2 100644 --- a/netbox/extras/filters.py +++ b/netbox/extras/filters.py @@ -180,6 +180,17 @@ class ConfigContextFilter(django_filters.FilterSet): to_field_name='slug', label='Tenant (slug)', ) + tag_id = django_filters.ModelMultipleChoiceFilter( + field_name='tags', + queryset=Tag.objects.all(), + label='Tag', + ) + 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..2c1201dee 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -246,7 +246,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,6 +266,9 @@ class ConfigContextForm(BootstrapMixin, forms.ModelForm): ), 'tenants': APISelectMultiple( api_url="/api/tenancy/tenants/" + ), + 'tags': APISelectMultiple( + api_url="/api/extras/tags/" ) } @@ -347,6 +350,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/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 52dea8674..1f31a7670 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -673,6 +673,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..10d23d15e 100644 --- a/netbox/extras/querysets.py +++ b/netbox/extras/querysets.py @@ -39,6 +39,8 @@ class ConfigContextQuerySet(QuerySet): else: regions = [] + tags = obj.tags.slugs() + return self.filter( Q(regions__in=regions) | Q(regions=None), Q(sites=obj.site) | Q(sites=None), @@ -46,5 +48,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__name__in=tags) | 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..8f692e11c 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-ta-1') + tag2 = Tag.objects.create(name='Test Tag 2', slug='test-ta-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.pk, tag2.pk], '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.pk, data['tags'][0]) + self.assertEqual(tag2.pk, data['tags'][1]) self.assertEqual(configcontext4.data, data['data']) def test_create_configcontext_bulk(self): 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/views.py b/netbox/utilities/views.py index 5631a6ec6..10048976b 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -144,7 +144,7 @@ class ObjectListView(View): table.columns.show('pk') # Construct queryset for tags list - if hasattr(model, 'tags'): + if hasattr(model, 'tags') and type(model.tags).__name__ is not 'ManyToManyDescriptor': tags = model.tags.annotate(count=Count('extras_taggeditem_items')).order_by('name') else: tags = None From ce4a5a38a31384accbc13168d65c0d26b6cfe44d Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 11 Dec 2019 15:26:47 -0500 Subject: [PATCH 2/4] Introduce is_taggable utility function for identifying taggable models --- netbox/extras/middleware.py | 3 ++- netbox/extras/utils.py | 15 +++++++++++++++ netbox/utilities/utils.py | 5 +++-- netbox/utilities/views.py | 3 ++- 4 files changed, 22 insertions(+), 4 deletions(-) create mode 100644 netbox/extras/utils.py 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/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/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 10048976b..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') and type(model.tags).__name__ is not 'ManyToManyDescriptor': + if is_taggable(model): tags = model.tags.annotate(count=Count('extras_taggeditem_items')).order_by('name') else: tags = None From fd88ba65b2c56db39a657ad11cd81581b23bc45e Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 11 Dec 2019 15:55:33 -0500 Subject: [PATCH 3/4] Cleanup for #3664 --- netbox/extras/filters.py | 6 ------ netbox/extras/forms.py | 12 ++++++++---- netbox/extras/querysets.py | 4 +--- 3 files changed, 9 insertions(+), 13 deletions(-) diff --git a/netbox/extras/filters.py b/netbox/extras/filters.py index 52b2776b2..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,11 +179,6 @@ class ConfigContextFilter(django_filters.FilterSet): to_field_name='slug', label='Tenant (slug)', ) - tag_id = django_filters.ModelMultipleChoiceFilter( - field_name='tags', - queryset=Tag.objects.all(), - label='Tag', - ) tag = django_filters.ModelMultipleChoiceFilter( field_name='tags__slug', queryset=Tag.objects.all(), diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index 2c1201dee..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='' ) @@ -267,9 +274,6 @@ class ConfigContextForm(BootstrapMixin, forms.ModelForm): 'tenants': APISelectMultiple( api_url="/api/tenancy/tenants/" ), - 'tags': APISelectMultiple( - api_url="/api/extras/tags/" - ) } diff --git a/netbox/extras/querysets.py b/netbox/extras/querysets.py index 10d23d15e..22ab489bd 100644 --- a/netbox/extras/querysets.py +++ b/netbox/extras/querysets.py @@ -39,8 +39,6 @@ class ConfigContextQuerySet(QuerySet): else: regions = [] - tags = obj.tags.slugs() - return self.filter( Q(regions__in=regions) | Q(regions=None), Q(sites=obj.site) | Q(sites=None), @@ -48,6 +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__name__in=tags) | Q(tags=None), + Q(tags__slug__in=obj.tags.slugs()) | Q(tags=None), is_active=True, ).order_by('weight', 'name') From 19363add3015576933d88001c6b766d5a014e165 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 11 Dec 2019 16:04:43 -0500 Subject: [PATCH 4/4] Represent and assign ConfigContext tags by their slugs --- netbox/extras/api/serializers.py | 4 ++-- netbox/extras/tests/test_api.py | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index 6872bab72..f167dee5c 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -173,9 +173,9 @@ class ConfigContextSerializer(ValidatedModelSerializer): required=False, many=True ) - tags = SerializedPKRelatedField( + tags = serializers.SlugRelatedField( queryset=Tag.objects.all(), - serializer=TagSerializer, + slug_field='slug', required=False, many=True ) diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py index 8f692e11c..df510aa17 100644 --- a/netbox/extras/tests/test_api.py +++ b/netbox/extras/tests/test_api.py @@ -370,8 +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-ta-1') - tag2 = Tag.objects.create(name='Test Tag 2', slug='test-ta-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', @@ -382,7 +382,7 @@ class ConfigContextTest(APITestCase): 'platforms': [platform1.pk, platform2.pk], 'tenant_groups': [tenantgroup1.pk, tenantgroup2.pk], 'tenants': [tenant1.pk, tenant2.pk], - 'tags': [tag1.pk, tag2.pk], + 'tags': [tag1.slug, tag2.slug], 'data': {'foo': 'XXX'} } @@ -405,8 +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.pk, data['tags'][0]) - self.assertEqual(tag2.pk, data['tags'][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):