diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index 8678d42a2..5ab28e8b7 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -2,6 +2,7 @@ from __future__ import unicode_literals from django.core.exceptions import ObjectDoesNotExist from rest_framework import serializers +from taggit.models import Tag from dcim.api.serializers import NestedDeviceSerializer, NestedRackSerializer, NestedSiteSerializer from dcim.models import Device, Rack, Site @@ -62,6 +63,18 @@ class TopologyMapSerializer(ValidatedModelSerializer): fields = ['id', 'name', 'slug', 'site', 'device_patterns', 'description'] +# +# Tags +# + +class TagSerializer(ValidatedModelSerializer): + tagged_items = serializers.IntegerField(read_only=True) + + class Meta: + model = Tag + fields = ['id', 'name', 'slug', 'tagged_items'] + + # # Image attachments # diff --git a/netbox/extras/api/urls.py b/netbox/extras/api/urls.py index cc278644d..4e1f9d2ef 100644 --- a/netbox/extras/api/urls.py +++ b/netbox/extras/api/urls.py @@ -28,6 +28,9 @@ router.register(r'export-templates', views.ExportTemplateViewSet) # Topology maps router.register(r'topology-maps', views.TopologyMapViewSet) +# Tags +router.register(r'tags', views.TagViewSet) + # Image attachments router.register(r'image-attachments', views.ImageAttachmentViewSet) diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 047abcb44..37d07060b 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -1,12 +1,14 @@ from __future__ import unicode_literals from django.contrib.contenttypes.models import ContentType +from django.db.models import Count from django.http import Http404, HttpResponse from django.shortcuts import get_object_or_404 from rest_framework.decorators import detail_route from rest_framework.exceptions import PermissionDenied from rest_framework.response import Response from rest_framework.viewsets import ReadOnlyModelViewSet, ViewSet +from taggit.models import Tag from extras import filters from extras.models import CustomField, ExportTemplate, Graph, ImageAttachment, ReportResult, TopologyMap, UserAction @@ -109,6 +111,16 @@ class TopologyMapViewSet(ModelViewSet): return response +# +# Tags +# + +class TagViewSet(ModelViewSet): + queryset = Tag.objects.annotate(tagged_items=Count('taggit_taggeditem_items')) + serializer_class = serializers.TagSerializer + filter_class = filters.TagFilter + + # # Image attachments # diff --git a/netbox/extras/filters.py b/netbox/extras/filters.py index 4a991471b..bb1202e28 100644 --- a/netbox/extras/filters.py +++ b/netbox/extras/filters.py @@ -3,6 +3,8 @@ from __future__ import unicode_literals import django_filters from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType +from django.db.models import Q +from taggit.models import Tag from dcim.models import Site from .constants import CF_FILTER_DISABLED, CF_FILTER_EXACT, CF_TYPE_BOOLEAN, CF_TYPE_SELECT @@ -85,6 +87,25 @@ class ExportTemplateFilter(django_filters.FilterSet): fields = ['content_type', 'name'] +class TagFilter(django_filters.FilterSet): + q = django_filters.CharFilter( + method='search', + label='Search', + ) + + class Meta: + model = Tag + fields = ['name', 'slug'] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(name__icontains=value) | + Q(slug__icontains=value) + ) + + class TopologyMapFilter(django_filters.FilterSet): site_id = django_filters.ModelMultipleChoiceFilter( name='site', diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py index 036d8143c..f0cdb5dfe 100644 --- a/netbox/extras/tests/test_api.py +++ b/netbox/extras/tests/test_api.py @@ -5,6 +5,7 @@ from django.contrib.contenttypes.models import ContentType from django.urls import reverse from rest_framework import status from rest_framework.test import APITestCase +from taggit.models import Tag from dcim.models import Device from extras.constants import GRAPH_TYPE_SITE @@ -226,3 +227,96 @@ class ExportTemplateTest(HttpStatusMixin, APITestCase): self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertEqual(ExportTemplate.objects.count(), 2) + + +class TagTest(HttpStatusMixin, APITestCase): + + def setUp(self): + + user = User.objects.create(username='testuser', is_superuser=True) + token = Token.objects.create(user=user) + self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + + self.tag1 = Tag.objects.create(name='Test Tag 1', slug='test-tag-1') + self.tag2 = Tag.objects.create(name='Test Tag 2', slug='test-tag-2') + self.tag3 = Tag.objects.create(name='Test Tag 3', slug='test-tag-3') + + def test_get_tag(self): + + url = reverse('extras-api:tag-detail', kwargs={'pk': self.tag1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['name'], self.tag1.name) + + def test_list_tags(self): + + url = reverse('extras-api:tag-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 3) + + def test_create_tag(self): + + data = { + 'name': 'Test Tag 4', + 'slug': 'test-tag-4', + } + + url = reverse('extras-api:tag-list') + response = self.client.post(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(Tag.objects.count(), 4) + tag4 = Tag.objects.get(pk=response.data['id']) + self.assertEqual(tag4.name, data['name']) + self.assertEqual(tag4.slug, data['slug']) + + def test_create_tag_bulk(self): + + data = [ + { + 'name': 'Test Tag 4', + 'slug': 'test-tag-4', + }, + { + 'name': 'Test Tag 5', + 'slug': 'test-tag-5', + }, + { + 'name': 'Test Tag 6', + 'slug': 'test-tag-6', + }, + ] + + url = reverse('extras-api:tag-list') + response = self.client.post(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(Tag.objects.count(), 6) + self.assertEqual(response.data[0]['name'], data[0]['name']) + self.assertEqual(response.data[1]['name'], data[1]['name']) + self.assertEqual(response.data[2]['name'], data[2]['name']) + + def test_update_tag(self): + + data = { + 'name': 'Test Tag X', + 'slug': 'test-tag-x', + } + + url = reverse('extras-api:tag-detail', kwargs={'pk': self.tag1.pk}) + response = self.client.put(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(Tag.objects.count(), 3) + tag1 = Tag.objects.get(pk=response.data['id']) + self.assertEqual(tag1.name, data['name']) + self.assertEqual(tag1.slug, data['slug']) + + def test_delete_tag(self): + + url = reverse('extras-api:tag-detail', kwargs={'pk': self.tag1.pk}) + response = self.client.delete(url, **self.header) + + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) + self.assertEqual(Tag.objects.count(), 2)